feat(gift): 선물 사이즈 가격 조회·수정 및 최초 등록 추가

고정된 3종 사이즈 중 미등록 사이즈는 기존 PUT API로 최초 등록하고, 등록된 사이즈는 가격과 활성 여부만 수정하도록 구성한다.

관리자 메뉴와 라우트, 가격 검증, 저장 확인 및 목록 재조회와 관련 테스트를 추가한다.
This commit is contained in:
Yu Sung
2026-10-01 13:40:31 +09:00
parent 596aab54b6
commit 00e6ba90f6
8 changed files with 972 additions and 3 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"serve": "vue-cli-service serve --port 8888", "serve": "vue-cli-service serve --port 8888",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"build_development": "vue-cli-service build --mode development", "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" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
+17
View File
@@ -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 }
+5 -1
View File
@@ -115,6 +115,9 @@ export default {
if (!giftMenu.items.some(item => item && item.route === '/gift/category')) { if (!giftMenu.items.some(item => item && item.route === '/gift/category')) {
giftMenu.items.push({ title: '선물 카테고리', route: '/gift/category', items: null }) 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 { } else {
this.items.push({ this.items.push({
title: '선물함 관리', title: '선물함 관리',
@@ -122,7 +125,8 @@ export default {
icon: 'mdi-gift', icon: 'mdi-gift',
items: [ items: [
{ title: '선물함 리스트', route: '/gift/list', items: null }, { title: '선물함 리스트', route: '/gift/list', items: null },
{ title: '선물 카테고리', route: '/gift/category', items: null } { title: '선물 카테고리', route: '/gift/category', items: null },
{ title: '선물 사이즈', route: '/gift/size', items: null }
] ]
}) })
} }
+5
View File
@@ -372,6 +372,11 @@ const routes = [
name: 'GiftCategory', name: 'GiftCategory',
component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftCategory.vue') component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftCategory.vue')
}, },
{
path: '/gift/size',
name: 'GiftSize',
component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftSize.vue')
},
{ {
path: '/original-work', path: '/original-work',
name: 'OriginalList', name: 'OriginalList',
+37
View File
@@ -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
}
+304
View File
@@ -0,0 +1,304 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>선물 사이즈</v-toolbar-title>
<v-spacer />
<v-btn
v-if="availableSizes.length"
color="primary"
:disabled="!canCreate"
@click="openCreate"
>
사이즈 등록
</v-btn>
</v-toolbar>
<br>
<v-container fluid>
<v-row>
<v-col>
<div class="mb-2">
총 {{ sizes.length }}건
</div>
<v-progress-linear
v-if="is_loading"
indeterminate
/>
<v-simple-table class="elevation-10">
<thead>
<tr>
<th
v-for="header in headers"
:key="header"
class="text-center"
>
{{ header }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in sizes"
:key="item.sizeCode"
>
<td class="text-center">
{{ item.sizeCode }}
</td>
<td class="text-center">
{{ item.name }}
</td>
<td class="text-center">
{{ item.basePriceCan.toLocaleString() }}
</td>
<td class="text-center">
{{ item.salePriceCan.toLocaleString() }}
</td>
<td class="text-center">
{{ item.isActive ? '활성' : '비활성' }}
</td>
<td class="text-center">
<v-btn
small
outlined
color="primary"
:disabled="is_loading || is_operating"
@click="openEdit(item)"
>
수정
</v-btn>
</td>
</tr>
<tr v-if="!sizes.length">
<td
:colspan="headers.length"
class="text-center grey--text"
>
{{ is_loading ? '조회 중입니다.' : list_error ? '목록을 불러오지 못했습니다.' : '등록된 선물 사이즈가 없습니다.' }}
</td>
</tr>
</tbody>
</v-simple-table>
<v-btn
v-if="list_error"
class="mt-3"
:disabled="is_loading || is_operating"
@click="getSizes"
>
다시 조회
</v-btn>
</v-col>
</v-row>
</v-container>
<v-dialog
v-model="show_form_dialog"
max-width="560px"
persistent
>
<v-card>
<v-form @submit.prevent="saveSize">
<v-card-title class="headline">
선물 사이즈 {{ isEdit ? '수정' : '등록' }}
</v-card-title>
<v-card-text>
<v-text-field
v-if="isEdit"
:value="form.sizeCode"
label="사이즈 코드"
readonly
:error-messages="form_errors.sizeCode"
/>
<v-select
v-else
v-model="form.sizeCode"
:items="availableSizes"
label="사이즈 코드"
hint="미등록 사이즈만 등록할 수 있습니다."
persistent-hint
:disabled="is_operating"
:error-messages="form_errors.sizeCode"
@change="$set(form_errors, 'sizeCode', '')"
/>
<v-text-field
v-if="isEdit"
:value="form.name"
label="사이즈명"
readonly
/>
<v-text-field
v-model="form.basePriceCan"
label="기본가"
type="number"
min="1"
step="1"
:disabled="is_operating"
:error-messages="form_errors.basePriceCan"
@input="clearPriceErrors"
/>
<v-text-field
v-model="form.salePriceCan"
label="판매가"
type="number"
min="1"
step="1"
:disabled="is_operating"
:error-messages="form_errors.salePriceCan"
@input="clearPriceErrors"
/>
<v-switch
v-model="form.isActive"
label="활성 여부"
:disabled="is_operating"
:error-messages="form_errors.isActive"
@change="$set(form_errors, 'isActive', '')"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
:disabled="is_operating"
@click="closeForm"
>
취소
</v-btn>
<v-btn
type="submit"
color="primary"
:loading="is_operating"
:disabled="is_operating || is_loading || (!isEdit && (!list_loaded || list_error))"
>
저장
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script>
import * as api from '@/api/gift_size'
import { giftSizes, createGiftSizeForm, buildGiftSizeRequest, validateGiftSize } from '@/utils/gift_size'
export default {
name: 'GiftSize',
data() {
return {
headers: ['사이즈 코드', '사이즈명', '기본가', '판매가', '활성 여부', '관리'],
sizes: [],
is_loading: false,
list_loaded: false,
list_error: false,
listRequestId: 0,
is_operating: false,
show_form_dialog: false,
isEdit: false,
form: createGiftSizeForm(),
form_errors: {}
}
},
computed: {
availableSizes() {
return giftSizes.filter(sizeCode => !this.sizes.some(item => item.sizeCode === sizeCode))
},
canCreate() {
return this.list_loaded && !this.list_error && !this.is_loading && !this.is_operating && this.availableSizes.length > 0
}
},
async created() {
await this.getSizes()
},
beforeDestroy() {
this.listRequestId++
},
methods: {
notifyError(error) {
const response = error.response && error.response.data
this.$dialog.notify.error((response && response.message) || error.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
},
checkResponse(response) {
if (response.success !== true) throw new Error(response.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
},
async getSizes() {
const requestId = ++this.listRequestId
this.is_loading = true
this.list_loaded = false
this.list_error = false
try {
const response = await api.getGiftSizePrices()
if (requestId !== this.listRequestId) return
this.checkResponse(response)
this.sizes = response.data
this.list_loaded = true
} catch (error) {
if (requestId !== this.listRequestId) return
this.sizes = []
this.list_error = true
this.notifyError(error)
} finally {
if (requestId === this.listRequestId) this.is_loading = false
}
},
openCreate() {
if (!this.canCreate) return
this.isEdit = false
this.form = createGiftSizeForm()
this.form_errors = {}
this.show_form_dialog = true
},
openEdit(item) {
if (this.is_loading || this.is_operating) return
this.isEdit = true
this.form = createGiftSizeForm(item)
this.form_errors = {}
this.show_form_dialog = true
},
closeForm() {
if (this.is_operating) return
this.show_form_dialog = false
},
clearPriceErrors() {
this.$set(this.form_errors, 'basePriceCan', '')
this.$set(this.form_errors, 'salePriceCan', '')
},
async saveSize() {
if (!this.show_form_dialog || this.is_operating || this.is_loading) return
if (!this.isEdit && (!this.list_loaded || this.list_error)) return
this.form_errors = validateGiftSize(this.form)
if (!this.isEdit && !this.form_errors.sizeCode && !this.availableSizes.includes(this.form.sizeCode)) {
this.$set(this.form_errors, 'sizeCode', '이미 등록된 사이즈입니다. 수정 버튼을 이용해 주세요.')
}
if (Object.keys(this.form_errors).length) return
const sizeCode = this.form.sizeCode
const request = buildGiftSizeRequest(this.form)
const action = this.isEdit ? '수정' : '등록'
this.is_operating = true
try {
const confirm = await this.$dialog.confirm({
title: `선물 사이즈 ${action} 확인`,
text: `${this.form.name || sizeCode} 사이즈의 가격과 활성 여부를 ${action}하시겠습니까?`,
actions: { false: '취소', true: '저장' }
})
if (!confirm) return
if (!this.isEdit && (!this.list_loaded || this.list_error || !this.availableSizes.includes(sizeCode))) {
this.$set(this.form_errors, 'sizeCode', '등록 가능한 사이즈를 다시 확인해 주세요.')
return
}
const response = await api.updateGiftSizePrice(sizeCode, request)
this.checkResponse(response)
this.$dialog.notify.success(`선물 사이즈가 ${action}되었습니다.`)
await this.getSizes()
this.show_form_dialog = false
} catch (error) {
this.notifyError(error)
} finally {
this.is_operating = false
}
}
}
}
</script>
+1 -1
View File
@@ -46,7 +46,7 @@ const detail = (overrides = {}) => ({
availableActions: ['ARRIVE_MAILBOX', 'MARK_UNDELIVERABLE'], availableActions: ['ARRIVE_MAILBOX', 'MARK_UNDELIVERABLE'],
senderInfo: { nickname: '발송인', name: '이름', phoneNumber: '010', address: '주소' }, senderInfo: { nickname: '발송인', name: '이름', phoneNumber: '010', address: '주소' },
recipientInfo: { nickname: '수취인', name: '', phoneNumber: '', 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 }, inboundDeliveryInfo: { courierCompanyName: null, trackingNumber: null },
...overrides ...overrides
}) })
+602
View File
@@ -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)
}
}
})()