diff --git a/package.json b/package.json index faf9ac0..785c621 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "serve": "vue-cli-service serve --port 8888", "build": "vue-cli-service build", "build_development": "vue-cli-service build --mode development", - "test:gift": "node tests/gift.test.js && node tests/gift_category.test.js", + "test:gift": "node tests/gift.test.js && node tests/gift_category.test.js && node tests/gift_size.test.js", "lint": "vue-cli-service lint" }, "dependencies": { diff --git a/src/api/gift_size.js b/src/api/gift_size.js new file mode 100644 index 0000000..756ed3d --- /dev/null +++ b/src/api/gift_size.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import { giftSizes, buildGiftSizeRequest } from '@/utils/gift_size' + +const baseUrl = '/api/v2/admin/gift-size-prices' + +async function getGiftSizePrices() { + const response = await Vue.axios.get(baseUrl) + return response.data +} + +async function updateGiftSizePrice(sizeCode, form) { + if (!giftSizes.includes(sizeCode)) throw new Error('올바른 선물 사이즈가 아닙니다.') + const response = await Vue.axios.put(`${baseUrl}/${sizeCode}`, buildGiftSizeRequest(form)) + return response.data +} + +export { getGiftSizePrices, updateGiftSizePrice } diff --git a/src/components/SideMenu.vue b/src/components/SideMenu.vue index fef1891..ae8eb45 100644 --- a/src/components/SideMenu.vue +++ b/src/components/SideMenu.vue @@ -115,6 +115,9 @@ export default { if (!giftMenu.items.some(item => item && item.route === '/gift/category')) { giftMenu.items.push({ title: '선물 카테고리', route: '/gift/category', items: null }) } + if (!giftMenu.items.some(item => item && item.route === '/gift/size')) { + giftMenu.items.push({ title: '선물 사이즈', route: '/gift/size', items: null }) + } } else { this.items.push({ title: '선물함 관리', @@ -122,7 +125,8 @@ export default { icon: 'mdi-gift', items: [ { title: '선물함 리스트', route: '/gift/list', items: null }, - { title: '선물 카테고리', route: '/gift/category', items: null } + { title: '선물 카테고리', route: '/gift/category', items: null }, + { title: '선물 사이즈', route: '/gift/size', items: null } ] }) } diff --git a/src/router/index.js b/src/router/index.js index 7ad5a6d..02f2cb0 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -372,6 +372,11 @@ const routes = [ name: 'GiftCategory', component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftCategory.vue') }, + { + path: '/gift/size', + name: 'GiftSize', + component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftSize.vue') + }, { path: '/original-work', name: 'OriginalList', diff --git a/src/utils/gift_size.js b/src/utils/gift_size.js new file mode 100644 index 0000000..16e96d2 --- /dev/null +++ b/src/utils/gift_size.js @@ -0,0 +1,37 @@ +export const giftSizes = Object.freeze(['SMALL', 'MEDIUM', 'LARGE']) + +export function createGiftSizeForm(item = {}) { + return { + sizeCode: item.sizeCode || '', + name: item.name || '', + basePriceCan: item.basePriceCan === undefined ? '' : item.basePriceCan, + salePriceCan: item.salePriceCan === undefined ? '' : item.salePriceCan, + isActive: item.isActive === undefined ? false : item.isActive + } +} + +export function buildGiftSizeRequest(form) { + return { + basePriceCan: Number(form.basePriceCan), + salePriceCan: Number(form.salePriceCan), + isActive: form.isActive + } +} + +export function validateGiftSize(form) { + const errors = {} + if (!giftSizes.includes(form.sizeCode)) errors.sizeCode = '올바른 선물 사이즈가 아닙니다.' + for (const [key, label] of [['basePriceCan', '기본가'], ['salePriceCan', '판매가']]) { + const value = form[key] + if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) { + errors[key] = `${label}를 입력해 주세요.` + } else if (!['number', 'string'].includes(typeof value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) { + errors[key] = `${label}는 1 이상의 정수로 입력해 주세요.` + } + } + if (!errors.basePriceCan && !errors.salePriceCan && Number(form.salePriceCan) > Number(form.basePriceCan)) { + errors.salePriceCan = '판매가는 기본가보다 클 수 없습니다.' + } + if (typeof form.isActive !== 'boolean') errors.isActive = '활성 여부를 선택해 주세요.' + return errors +} diff --git a/src/views/Gift/GiftSize.vue b/src/views/Gift/GiftSize.vue new file mode 100644 index 0000000..d1afdf8 --- /dev/null +++ b/src/views/Gift/GiftSize.vue @@ -0,0 +1,304 @@ + + + diff --git a/tests/gift.test.js b/tests/gift.test.js index 3c78700..70b8cdf 100644 --- a/tests/gift.test.js +++ b/tests/gift.test.js @@ -46,7 +46,7 @@ const detail = (overrides = {}) => ({ availableActions: ['ARRIVE_MAILBOX', 'MARK_UNDELIVERABLE'], senderInfo: { nickname: '발송인', name: '이름', phoneNumber: '010', address: '주소' }, recipientInfo: { nickname: '수취인', name: '', phoneNumber: '', address: '' }, - productInfo: { sizeName: '소형', sizeCode: 'S', categoryName: '문구', classificationNumber: 'C1' }, + productInfo: { sizeName: '소형', sizeCode: 'SMALL', categoryName: '문구', classificationNumber: 'C1' }, inboundDeliveryInfo: { courierCompanyName: null, trackingNumber: null }, ...overrides }) diff --git a/tests/gift_size.test.js b/tests/gift_size.test.js new file mode 100644 index 0000000..99b96f5 --- /dev/null +++ b/tests/gift_size.test.js @@ -0,0 +1,602 @@ +const assert = require('assert').strict +const fs = require('fs') +const path = require('path') +const Module = require('module') +const { transformSync } = require('@babel/core') +const compiler = require('vue-template-compiler') +const Vue = require('vue') + +const root = path.resolve(__dirname, '..') +const originalResolve = Module._resolveFilename +Module._resolveFilename = function(request, parent, ...rest) { + return originalResolve.call(this, request.startsWith('@/') ? path.join(root, 'src', request.slice(2)) : request, parent, ...rest) +} +const originalJs = require.extensions['.js'] +function compileScript(source, filename) { + return transformSync(source, { + filename, + babelrc: false, + configFile: false, + plugins: ['@babel/plugin-transform-modules-commonjs'] + }).code +} +require.extensions['.js'] = (module, filename) => { + if (!filename.startsWith(path.join(root, 'src') + path.sep)) return originalJs(module, filename) + module._compile(compileScript(fs.readFileSync(filename, 'utf8'), filename), filename) +} +require.extensions['.vue'] = (module, filename) => { + const { script, template } = compiler.parseComponent(fs.readFileSync(filename, 'utf8')) + const compiled = compiler.compile(template.content) + assert.deepEqual(compiled.errors, [], `${filename}: 템플릿 컴파일`) + module._compile(compileScript(script.content, filename), filename) + Object.assign(module.exports.default, compiler.compileToFunctions(template.content)) +} + +const api = require('../src/api/gift_size') +const { giftSizes, createGiftSizeForm, buildGiftSizeRequest, validateGiftSize } = require('../src/utils/gift_size') +const GiftSize = require('../src/views/Gift/GiftSize.vue').default +const SideMenu = require('../src/components/SideMenu.vue').default +const tests = [] +const test = (name, run) => tests.push({ name, run }) +const envelope = data => ({ success: true, data }) +const size = (overrides = {}) => ({ sizeCode: 'SMALL', name: '소형', basePriceCan: 12000, salePriceCan: 10000, isActive: true, ...overrides }) +const request = (overrides = {}) => ({ basePriceCan: 12000, salePriceCan: 10000, isActive: true, ...overrides }) +function page() { + const errors = [] + const successes = [] + const confirmations = [] + const vm = new Vue({ ...GiftSize, created: undefined }) + vm.$dialog = { + notify: { error: message => errors.push(message), success: message => successes.push(message) }, + confirm: async options => { confirmations.push(options); return true } + } + return { vm, errors, successes, confirmations } +} +function deferred() { + let resolve + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} +function nodes(vnode) { + if (!vnode) return [] + return [vnode].concat((vnode.children || (vnode.componentOptions && vnode.componentOptions.children) || []).flatMap(nodes)) +} +const text = vnode => nodes(vnode).map(node => node.text || '').join('').trim() +const button = (vm, label) => nodes(vm._render()).find(node => node.tag === 'v-btn' && text(node) === label) + +test('사이즈 API: 유니크한 GiftSize 3종의 GET/PUT 경로와 본문 및 envelope가 일치한다', async () => { + assert.deepEqual(giftSizes, ['SMALL', 'MEDIUM', 'LARGE']) + assert.equal(new Set(giftSizes).size, 3) + assert.deepEqual(Object.keys(api).sort(), ['getGiftSizePrices', 'updateGiftSizePrice']) + const calls = [] + const response = envelope(size()) + Vue.axios = Object.fromEntries(['get', 'put'].map(method => [method, async (...args) => { + calls.push([method, ...args]) + return { data: response } + }])) + assert.equal(await api.getGiftSizePrices(), response) + for (const sizeCode of giftSizes) { + assert.equal(await api.updateGiftSizePrice(sizeCode, size({ basePriceCan: '12000', salePriceCan: '10000', isActive: false, extra: '제외' })), response) + } + assert.deepEqual(calls, [ + ['get', '/api/v2/admin/gift-size-prices'], + ...giftSizes.map(code => ['put', `/api/v2/admin/gift-size-prices/${code}`, request({ isActive: false })]) + ]) + for (const code of ['S', 'M', 'L', 'small', '', 'XLARGE', null, undefined]) { + await assert.rejects(api.updateGiftSizePrice(code, size())) + } + assert.equal(calls.length, 4) +}) + +test('사이즈 validation: 필수·1 이상 정수·안전한 숫자·boolean·enum을 검사한다', () => { + const invalidPrices = ['', ' ', null, undefined, 0, '0', -1, '-1', 1.1, '1.1', 'abc', NaN, Infinity, 'Infinity', true, false, [], {}, Number.MAX_SAFE_INTEGER + 1] + for (const field of ['basePriceCan', 'salePriceCan']) { + for (const value of invalidPrices) { + const errors = validateGiftSize(size({ [field]: value })) + assert.deepEqual(Object.keys(errors), [field], `${field}: ${String(value)}`) + assert.ok(errors[field]) + } + } + for (const code of ['S', 'M', 'L', 'small', '', null, undefined, 'XLARGE']) { + assert.deepEqual(Object.keys(validateGiftSize(size({ sizeCode: code }))), ['sizeCode']) + } + for (const value of [null, undefined, 'true', 'false', 0, 1]) { + assert.deepEqual(Object.keys(validateGiftSize(size({ isActive: value }))), ['isActive']) + } +}) + +test('사이즈 validation: 최소값·동일 가격·숫자 문자열·false를 허용하고 초과 판매가는 거부한다', () => { + for (const sizeCode of giftSizes) { + for (const price of [1, '1', Number.MAX_SAFE_INTEGER]) { + const item = size({ sizeCode, basePriceCan: price, salePriceCan: price, isActive: false }) + assert.deepEqual(validateGiftSize(item), {}) + assert.deepEqual(buildGiftSizeRequest({ ...item, extra: '제외' }), { basePriceCan: Number(price), salePriceCan: Number(price), isActive: false }) + } + } + assert.deepEqual(Object.keys(validateGiftSize(size({ basePriceCan: '9', salePriceCan: '10' }))), ['salePriceCan']) + const form = createGiftSizeForm(size()) + assert.deepEqual(form, size()) + form.basePriceCan = 1 + assert.equal(createGiftSizeForm(size()).basePriceCan, 12000) +}) + +test('사이즈 목록: 최초 GET, 6개 컬럼, 숫자 포맷과 수정 전용 버튼을 표시한다', async () => { + const { vm } = page() + const rows = giftSizes.map(sizeCode => size({ sizeCode, isActive: sizeCode !== 'LARGE' })) + const expected = rows.map(item => ({ ...item })) + const calls = [] + Vue.axios = { get: async (...args) => { calls.push(args); return { data: envelope(rows) } } } + await GiftSize.created.call(vm) + assert.deepEqual(calls, [['/api/v2/admin/gift-size-prices']]) + assert.deepEqual(Array.from(vm.sizes), expected) + const rendered = nodes(vm._render()) + assert.equal(text(rendered.find(node => node.tag === 'v-toolbar-title')), '선물 사이즈') + assert.deepEqual(rendered.filter(node => node.tag === 'th').map(text), ['사이즈 코드', '사이즈명', '기본가', '판매가', '활성 여부', '관리']) + assert.deepEqual(rendered.filter(node => node.tag === 'td').slice(0, 6).map(text), ['SMALL', '소형', (12000).toLocaleString(), (10000).toLocaleString(), '활성', '수정']) + assert.ok(text(vm._render()).includes('비활성')) + assert.deepEqual(rendered.filter(node => node.tag === 'v-btn').map(text), ['수정', '수정', '수정', '취소', '저장']) + button(vm, '수정').data.on.click() + assert.deepEqual(vm.form, rows[0]) + assert.equal(vm.show_form_dialog, true) +}) + +test('사이즈 목록: 빈 목록·로딩·실패·재조회 및 늦은 응답을 처리한다', async () => { + const { vm, errors } = page() + assert.ok(text(vm._render()).includes('등록된 선물 사이즈가 없습니다.')) + const pending = deferred() + Vue.axios = { get: async () => pending.promise } + const old = vm.getSizes() + assert.equal(vm.is_loading, true) + assert.ok(text(vm._render()).includes('조회 중입니다.')) + vm.openEdit(size()) + assert.equal(vm.show_form_dialog, false) + Vue.axios.get = async () => ({ data: { success: false, message: '조회 실패' } }) + await vm.getSizes() + assert.deepEqual(errors, ['조회 실패']) + assert.equal(vm.list_error, true) + Vue.axios.get = async () => ({ data: envelope([size()]) }) + await button(vm, '다시 조회').data.on.click() + pending.resolve({ data: envelope([]) }) + await old + assert.deepEqual(Array.from(vm.sizes), [size()]) + assert.equal(vm.list_error, false) + assert.equal(vm.is_loading, false) +}) + +test('사이즈 최초 등록: 빈 목록에서 고정 3종만 선택하며 가격과 활성 여부를 입력한다', async () => { + const { vm } = page() + Vue.axios = { get: async () => ({ data: envelope([]) }) } + await GiftSize.created.call(vm) + const create = button(vm, '사이즈 등록') + assert.ok(create, '빈 목록에서도 최초 등록 버튼이 있어야 한다') + assert.equal(create.data.attrs.disabled, false) + create.data.on.click() + assert.equal(vm.show_form_dialog, true) + assert.deepEqual(vm.form, createGiftSizeForm()) + const rendered = nodes(vm._render()) + assert.ok(text(vm._render()).includes('선물 사이즈 등록')) + const select = rendered.find(node => node.tag === 'v-select') + assert.ok(select) + assert.equal(select.data.attrs.label, '사이즈 코드') + assert.deepEqual(Array.from(select.data.attrs.items), ['SMALL', 'MEDIUM', 'LARGE']) + select.data.model.callback('MEDIUM') + assert.equal(vm.form.sizeCode, 'MEDIUM') + const prices = rendered.filter(node => node.tag === 'v-text-field') + assert.deepEqual(prices.map(node => node.data.attrs.label), ['기본가', '판매가']) + assert.ok(prices.every(node => node.data.attrs.type === 'number')) + assert.equal(rendered.filter(node => node.tag === 'v-switch').length, 1) + assert.equal(button(vm, '삭제'), undefined) +}) + +test('사이즈 최초 등록: 비활성을 포함한 등록 코드는 제외하고 3종 모두 등록되면 추가할 수 없다', async () => { + const { vm } = page() + Vue.axios = { get: async () => ({ data: envelope([size({ isActive: false }), size({ sizeCode: 'LARGE' })]) }) } + await vm.getSizes() + button(vm, '사이즈 등록').data.on.click() + const select = nodes(vm._render()).find(node => node.tag === 'v-select') + assert.deepEqual(Array.from(select.data.attrs.items), ['MEDIUM']) + vm.closeForm() + Vue.axios.get = async () => ({ data: envelope(giftSizes.map(sizeCode => size({ sizeCode, isActive: false }))) }) + await vm.getSizes() + assert.equal(button(vm, '사이즈 등록'), undefined) + vm.openCreate() + assert.equal(vm.show_form_dialog, false) + assert.equal(nodes(vm._render()).filter(node => node.tag === 'v-btn' && text(node) === '수정').length, 3) +}) + +test('사이즈 최초 등록: 최초 조회 전·조회 중·조회 실패에는 등록을 차단하고 재조회 후 허용한다', async () => { + const { vm } = page() + assert.equal(button(vm, '사이즈 등록').data.attrs.disabled, true) + vm.openCreate() + assert.equal(vm.show_form_dialog, false) + const pending = deferred() + Vue.axios = { get: async () => pending.promise } + const load = vm.getSizes() + assert.equal(button(vm, '사이즈 등록').data.attrs.disabled, true) + vm.openCreate() + assert.equal(vm.show_form_dialog, false) + pending.resolve({ data: { success: false, message: '조회 실패' } }) + await load + assert.equal(button(vm, '사이즈 등록').data.attrs.disabled, true) + vm.openCreate() + assert.equal(vm.show_form_dialog, false) + Vue.axios.get = async () => ({ data: envelope([]) }) + await button(vm, '다시 조회').data.on.click() + assert.equal(button(vm, '사이즈 등록').data.attrs.disabled, false) + vm.openCreate() + assert.equal(vm.show_form_dialog, true) +}) + +test('사이즈 최초 등록: 미선택·잘못된 코드·기등록 코드·잘못된 가격은 필드 오류로 표시하고 PUT하지 않는다', async () => { + for (const overrides of [ + { sizeCode: '' }, { sizeCode: 'XLARGE' }, { sizeCode: 'SMALL' }, + { basePriceCan: '' }, { salePriceCan: '' }, { basePriceCan: 0 }, { salePriceCan: 1.5 }, { salePriceCan: 12001 } + ]) { + const { vm, confirmations } = page() + let calls = 0 + Vue.axios = { + get: async () => ({ data: envelope([size()]) }), + put: async () => { calls++ } + } + await vm.getSizes() + vm.openCreate() + Object.assign(vm.form, size({ sizeCode: 'MEDIUM', ...overrides })) + await vm.saveSize() + assert.equal(calls, 0) + assert.equal(confirmations.length, 0) + const labels = { sizeCode: '사이즈 코드', basePriceCan: '기본가', salePriceCan: '판매가' } + const label = labels[Object.keys(overrides)[0]] + const field = nodes(vm._render()).find(node => ['v-text-field', 'v-select'].includes(node.tag) && node.data.attrs.label === label) + assert.ok(field.data.attrs['error-messages']) + if (overrides.salePriceCan === 12001) assert.equal(field.data.attrs['error-messages'], '판매가는 기본가보다 클 수 없습니다.') + assert.equal(vm.show_form_dialog, true) + } +}) + +test('사이즈 최초 등록: 취소·수정 후 다시 열면 등록 폼과 오류를 초기화한다', async () => { + const { vm } = page() + Vue.axios = { get: async () => ({ data: envelope([size()]) }) } + await vm.getSizes() + vm.openCreate() + Object.assign(vm.form, size({ sizeCode: 'MEDIUM' })) + vm.form_errors = { sizeCode: '오류', salePriceCan: '오류' } + vm.closeForm() + vm.openCreate() + assert.deepEqual(vm.form, createGiftSizeForm()) + assert.deepEqual(vm.form_errors, {}) + vm.closeForm() + vm.openEdit(vm.sizes[0]) + assert.equal(nodes(vm._render()).find(node => node.tag === 'v-select'), undefined) + assert.ok(text(vm._render()).includes('선물 사이즈 수정')) + vm.closeForm() + vm.openCreate() + assert.deepEqual(vm.form, createGiftSizeForm()) + assert.ok(text(vm._render()).includes('선물 사이즈 등록')) + assert.deepEqual(Array.from(vm.sizes), [size()]) +}) + +test('사이즈 최초 등록: 고정 3종을 기존 PUT으로 등록하고 정확한 본문·확인창·토스트·재조회를 제공한다', async () => { + const { vm, successes, confirmations } = page() + const rows = [] + const calls = [] + Vue.axios = { get: async () => ({ data: envelope([]) }) } + await vm.getSizes() + for (const [index, sizeCode] of giftSizes.entries()) { + const pending = deferred() + const saved = size({ sizeCode, name: `서버 이름 ${index}`, basePriceCan: 13000, salePriceCan: 11000, isActive: index !== 1 }) + Vue.axios = { + put: async (...args) => { calls.push(['PUT', ...args]); rows.push(saved); return { data: envelope(saved) } }, + get: async (...args) => { calls.push(['GET', ...args]); return pending.promise } + } + button(vm, '사이즈 등록').data.on.click() + Object.assign(vm.form, { sizeCode, basePriceCan: '13000', salePriceCan: '11000', isActive: saved.isActive, extra: '제외' }) + const save = vm.saveSize() + await Vue.nextTick() + assert.equal(vm.show_form_dialog, true) + assert.equal(vm.is_operating, true) + assert.equal(button(vm, '사이즈 등록').data.attrs.disabled, true) + await vm.saveSize() + pending.resolve({ data: envelope([...rows]) }) + await save + assert.deepEqual(calls.slice(index * 2), [ + ['PUT', `/api/v2/admin/gift-size-prices/${sizeCode}`, request({ basePriceCan: 13000, salePriceCan: 11000, isActive: saved.isActive })], + ['GET', '/api/v2/admin/gift-size-prices'] + ]) + assert.equal(confirmations[index].title, '선물 사이즈 등록 확인') + assert.ok(confirmations[index].text.includes(sizeCode)) + assert.deepEqual(confirmations[index].actions, { false: '취소', true: '저장' }) + assert.equal(successes[index], '선물 사이즈가 등록되었습니다.') + assert.deepEqual(Array.from(vm.sizes), rows) + assert.ok(text(vm._render()).includes(saved.name)) + assert.equal(vm.show_form_dialog, false) + assert.equal(vm.is_operating, false) + } + assert.equal(button(vm, '사이즈 등록'), undefined) + assert.equal(calls.length, 6) +}) + +test('사이즈 최초 등록: 확인 취소·중복 제출을 차단하며 저장 중에는 코드와 입력값을 바꿀 수 없다', async () => { + const { vm } = page() + const pending = deferred() + let calls = 0 + let confirms = 0 + Vue.axios = { get: async () => ({ data: envelope([]) }), put: async () => { calls++ } } + await vm.getSizes() + vm.openCreate() + Object.assign(vm.form, size()) + vm.$dialog.confirm = async () => { confirms++; return pending.promise } + const save = vm.saveSize() + await vm.saveSize() + vm.openCreate() + vm.openEdit(size({ sizeCode: 'LARGE' })) + vm.closeForm() + assert.deepEqual(vm.form, size()) + assert.equal(vm.show_form_dialog, true) + assert.equal(confirms, 1) + assert.equal(calls, 0) + const rendered = nodes(vm._render()) + for (const field of rendered.filter(node => ['v-select', 'v-text-field', 'v-switch'].includes(node.tag))) { + assert.equal(field.data.attrs.disabled, true) + } + assert.equal(button(vm, '저장').data.attrs.disabled, true) + assert.equal(button(vm, '취소').data.attrs.disabled, true) + pending.resolve(false) + await save + assert.equal(calls, 0) + assert.equal(vm.is_operating, false) + assert.equal(vm.show_form_dialog, true) +}) + +test('사이즈 최초 등록: 폼을 연 뒤나 확인 중 목록이 변경되면 기존 데이터 덮어쓰기를 차단한다', async () => { + for (const timing of ['before', 'confirm']) { + for (const state of ['registered', 'loading', 'failure']) { + const { vm } = page() + const confirmation = deferred() + const pending = deferred() + let calls = 0 + Vue.axios = { get: async () => ({ data: envelope([]) }), put: async () => { calls++ } } + await vm.getSizes() + vm.openCreate() + Object.assign(vm.form, size()) + vm.$dialog.confirm = async () => confirmation.promise + const save = timing === 'confirm' ? vm.saveSize() : null + Vue.axios.get = state === 'loading' + ? async () => pending.promise + : async () => ({ data: state === 'failure' ? { success: false, message: '조회 실패' } : envelope([size()]) }) + const load = vm.getSizes() + if (state !== 'loading') await load + confirmation.resolve(true) + if (timing === 'confirm') await save + else await vm.saveSize() + assert.equal(calls, 0) + assert.equal(vm.show_form_dialog, true) + assert.equal(vm.is_operating, false) + if (timing === 'confirm' || state === 'registered') assert.ok(vm.form_errors.sizeCode) + if (state === 'loading') { + pending.resolve({ data: envelope([]) }) + await load + } + } + } +}) + +test('사이즈 최초 등록 실패: 서버 오류 시 입력을 유지하고 재조회 실패 시 중복 등록 진입을 차단한다', async () => { + for (const fail of [ + async () => ({ data: { success: false, message: '등록 실패' } }), + async () => { throw { response: { data: { message: '등록 실패' } } } } + ]) { + const { vm, errors, successes } = page() + let loads = 0 + Vue.axios = { get: async () => { loads++; return { data: envelope([]) } }, put: fail } + await vm.getSizes() + vm.openCreate() + Object.assign(vm.form, size()) + await vm.saveSize() + assert.deepEqual(errors, ['등록 실패']) + assert.deepEqual(successes, []) + assert.deepEqual(vm.form, size()) + assert.equal(vm.show_form_dialog, true) + assert.equal(vm.is_operating, false) + assert.equal(loads, 1) + } + const { vm, errors, successes } = page() + Vue.axios = { get: async () => ({ data: envelope([]) }), put: async () => ({ data: envelope(size()) }) } + await vm.getSizes() + vm.openCreate() + Object.assign(vm.form, size()) + Vue.axios.get = async () => { throw new Error('조회 실패') } + await vm.saveSize() + assert.deepEqual(successes, ['선물 사이즈가 등록되었습니다.']) + assert.deepEqual(errors, ['조회 실패']) + assert.equal(vm.show_form_dialog, false) + assert.equal(button(vm, '사이즈 등록').data.attrs.disabled, true) + vm.openCreate() + assert.equal(vm.show_form_dialog, false) + Vue.axios.get = async () => ({ data: envelope([size()]) }) + await button(vm, '다시 조회').data.on.click() + vm.openCreate() + const select = nodes(vm._render()).find(node => node.tag === 'v-select') + assert.deepEqual(Array.from(select.data.attrs.items), ['MEDIUM', 'LARGE']) +}) + +test('사이즈 폼: 코드와 이름은 읽기 전용, 가격은 number, 취소는 row를 변경하지 않는다', () => { + const { vm } = page() + const item = size() + vm.openEdit(item) + const fields = nodes(vm._render()).filter(node => node.tag === 'v-text-field') + assert.equal(fields.length, 4) + for (const field of fields) { + const attrs = field.data.attrs + if (['사이즈 코드', '사이즈명'].includes(attrs.label)) assert.ok(attrs.readonly !== undefined && attrs.readonly !== false) + else { + assert.equal(attrs.type, 'number') + assert.equal(Number(attrs.min), 1) + assert.equal(Number(attrs.step), 1) + } + } + assert.equal(nodes(vm._render()).filter(node => node.tag === 'v-switch').length, 1) + vm.form.basePriceCan = 123 + vm.form.isActive = false + vm.closeForm() + assert.deepEqual(item, size()) + vm.form_errors = { salePriceCan: '오류' } + vm.openEdit(size({ sizeCode: 'MEDIUM', basePriceCan: 20000 })) + assert.equal(vm.form.sizeCode, 'MEDIUM') + assert.equal(vm.form.basePriceCan, 20000) + assert.deepEqual(vm.form_errors, {}) +}) + +test('사이즈 폼: 잘못된 가격이면 API/확인창 없이 해당 필드 오류를 표시한다', async () => { + for (const overrides of [{ salePriceCan: 12001 }, { basePriceCan: '' }, { salePriceCan: '' }, { basePriceCan: 0 }, { salePriceCan: 1.5 }]) { + const { vm, confirmations } = page() + let calls = 0 + Vue.axios = { put: async () => { calls++ } } + vm.openEdit(size(overrides)) + const form = nodes(vm._render()).find(node => node.tag === 'v-form') + await form.data.on.submit({ preventDefault() {} }) + assert.equal(calls, 0) + assert.equal(confirmations.length, 0) + const label = Object.keys(overrides)[0] === 'basePriceCan' ? '기본가' : '판매가' + const field = nodes(vm._render()).find(node => node.tag === 'v-text-field' && node.data.attrs.label === label) + assert.ok(field.data.attrs['error-messages']) + if (overrides.salePriceCan === 12001) assert.equal(field.data.attrs['error-messages'], '판매가는 기본가보다 클 수 없습니다.') + assert.equal(vm.show_form_dialog, true) + assert.equal(vm.is_operating, false) + } +}) + +test('사이즈 저장: 확인 전/취소 후에는 PUT하지 않고 중복 제출과 폼 변경을 차단한다', async () => { + const { vm } = page() + let calls = 0 + const pending = deferred() + let confirms = 0 + vm.$dialog.confirm = async () => { confirms++; return pending.promise } + Vue.axios = { put: async () => { calls++ } } + vm.openEdit(size()) + const save = vm.saveSize() + await vm.saveSize() + vm.closeForm() + vm.openEdit(size({ sizeCode: 'LARGE' })) + assert.equal(confirms, 1) + assert.equal(calls, 0) + assert.equal(vm.form.sizeCode, 'SMALL') + assert.equal(vm.show_form_dialog, true) + assert.equal(button(vm, '저장').data.attrs.disabled, true) + assert.equal(button(vm, '취소').data.attrs.disabled, true) + pending.resolve(false) + await save + assert.equal(calls, 0) + assert.equal(vm.is_operating, false) + assert.equal(vm.show_form_dialog, true) +}) + +test('사이즈 저장: PUT의 정확한 본문, 성공 토스트, 목록 refresh 후 모달 닫기를 보장한다', async () => { + for (const sizeCode of giftSizes) { + const { vm, successes, confirmations } = page() + const pending = deferred() + const calls = [] + const saved = size({ sizeCode, basePriceCan: 13000, salePriceCan: 11000, isActive: false }) + Vue.axios = { + put: async (...args) => { calls.push(['PUT', ...args]); return { data: envelope(saved) } }, + get: async (...args) => { calls.push(['GET', ...args]); return pending.promise } + } + vm.openEdit(size({ sizeCode })) + vm.form.basePriceCan = '13000' + vm.form.salePriceCan = '11000' + vm.form.isActive = false + vm.form.extra = '제외' + const save = vm.saveSize() + await Vue.nextTick() + assert.equal(vm.show_form_dialog, true) + assert.equal(vm.is_operating, true) + await vm.saveSize() + pending.resolve({ data: envelope([saved]) }) + await save + assert.deepEqual(calls, [ + ['PUT', `/api/v2/admin/gift-size-prices/${sizeCode}`, request({ basePriceCan: 13000, salePriceCan: 11000, isActive: false })], + ['GET', '/api/v2/admin/gift-size-prices'] + ]) + assert.equal(confirmations.length, 1) + assert.deepEqual(confirmations[0].actions, { false: '취소', true: '저장' }) + assert.deepEqual(successes, ['선물 사이즈가 수정되었습니다.']) + assert.deepEqual(Array.from(vm.sizes), [saved]) + assert.equal(vm.show_form_dialog, false) + assert.equal(vm.is_operating, false) + await vm.saveSize() + assert.equal(calls.length, 2) + } +}) + +test('사이즈 저장 실패: envelope/HTTP 오류를 알리고 입력값과 모달을 유지한다', async () => { + for (const fail of [ + async () => ({ data: { success: false, message: '저장 실패' } }), + async () => { throw { response: { data: { message: '저장 실패' } } } }, + async () => { throw new Error('저장 실패') } + ]) { + const { vm, errors, successes } = page() + let loads = 0 + Vue.axios = { put: fail, get: async () => { loads++ } } + vm.openEdit(size()) + await vm.saveSize() + assert.deepEqual(errors, ['저장 실패']) + assert.deepEqual(successes, []) + assert.deepEqual(vm.form, size()) + assert.equal(vm.show_form_dialog, true) + assert.equal(vm.is_operating, false) + assert.equal(loads, 0) + } +}) + +test('사이즈 저장 후 조회 실패: 저장 성공과 조회 실패를 구분하고 재시도를 제공한다', async () => { + const { vm, errors, successes } = page() + Vue.axios = { + put: async () => ({ data: envelope(size()) }), + get: async () => { throw new Error('조회 실패') } + } + vm.openEdit(size()) + await vm.saveSize() + assert.deepEqual(successes, ['선물 사이즈가 수정되었습니다.']) + assert.deepEqual(errors, ['조회 실패']) + assert.equal(vm.show_form_dialog, false) + assert.equal(vm.list_error, true) + assert.equal(vm.is_operating, false) + assert.ok(button(vm, '다시 조회')) +}) + +test('사이즈 메뉴/라우트: ADMIN만 보완하고 기존 서버 항목을 중복 없이 유지한다', async () => { + for (const role of ['ADMIN', 'CONTENT_MANAGER']) { + for (const existing of ['none', 'null', 'list', 'size', 'object']) { + const menus = [] + if (existing !== 'none') { + const item = { title: existing === 'list' ? '선물함 리스트' : '선물 사이즈', route: existing === 'list' ? '/gift/list' : '/gift/size' } + menus.push({ title: '선물함 관리', items: existing === 'null' ? null : existing === 'object' ? item : [item] }) + } + Vue.axios = { get: async () => ({ status: 200, data: envelope(menus) }) } + const vm = new Vue({ ...SideMenu, created: undefined }) + vm.$store = { state: { accountStore: { role } } } + await vm.getMenus() + const gifts = vm.items.filter(item => item.title === '선물함 관리') + const children = gifts.length && gifts[0].items ? [].concat(gifts[0].items) : [] + assert.equal(children.filter(item => item.route === '/gift/size').length, role === 'ADMIN' || ['size', 'object'].includes(existing) ? 1 : 0) + if (role === 'ADMIN') { + assert.equal(gifts.length, 1) + assert.equal(children.filter(item => item.route === '/gift/list').length, 1) + assert.equal(children.filter(item => item.route === '/gift/category').length, 1) + } + } + } + const router = fs.readFileSync(path.join(root, 'src/router/index.js'), 'utf8') + assert.match(router, /path: '\/gift\/size',\s*name: 'GiftSize',\s*component: \(\) => import\([^\n]*'\.\.\/views\/Gift\/GiftSize.vue'\)/) +}) + +;(async () => { + for (const { name, run } of tests) { + try { + await run() + console.log(`PASS ${name}`) + } catch (error) { + process.exitCode = 1 + console.error(`FAIL ${name}`, error) + } + } +})()