feat(gift): 관리자 선물 카테고리 CRUD 추가

This commit is contained in:
Yu Sung
2026-10-01 12:59:06 +09:00
parent 87d9121ca9
commit 596aab54b6
7 changed files with 837 additions and 2 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", "test:gift": "node tests/gift.test.js && node tests/gift_category.test.js",
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
+26
View File
@@ -0,0 +1,26 @@
import Vue from 'vue';
import { buildGiftCategoryRequest } from '@/utils/gift_category'
const baseUrl = '/api/v2/admin/gift-categories'
async function getGiftCategories() {
const response = await Vue.axios.get(baseUrl)
return response.data
}
async function createGiftCategory(form) {
const response = await Vue.axios.post(baseUrl, buildGiftCategoryRequest(form))
return response.data
}
async function updateGiftCategory(categoryId, form) {
const response = await Vue.axios.put(`${baseUrl}/${encodeURIComponent(categoryId)}`, buildGiftCategoryRequest(form))
return response.data
}
async function deactivateGiftCategory(categoryId) {
const response = await Vue.axios.delete(`${baseUrl}/${encodeURIComponent(categoryId)}`)
return response.data
}
export { getGiftCategories, createGiftCategory, updateGiftCategory, deactivateGiftCategory }
+7 -1
View File
@@ -112,12 +112,18 @@ export default {
if (!giftMenu.items.some(item => item && item.route === '/gift/list')) { if (!giftMenu.items.some(item => item && item.route === '/gift/list')) {
giftMenu.items.push({ title: '선물함 리스트', route: '/gift/list', items: null }) giftMenu.items.push({ title: '선물함 리스트', route: '/gift/list', items: null })
} }
if (!giftMenu.items.some(item => item && item.route === '/gift/category')) {
giftMenu.items.push({ title: '선물 카테고리', route: '/gift/category', items: null })
}
} else { } else {
this.items.push({ this.items.push({
title: '선물함 관리', title: '선물함 관리',
route: null, route: null,
icon: 'mdi-gift', icon: 'mdi-gift',
items: [{ title: '선물함 리스트', route: '/gift/list', items: null }] items: [
{ title: '선물함 리스트', route: '/gift/list', items: null },
{ title: '선물 카테고리', route: '/gift/category', items: null }
]
}) })
} }
+5
View File
@@ -367,6 +367,11 @@ const routes = [
name: 'GiftList', name: 'GiftList',
component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftList.vue') component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftList.vue')
}, },
{
path: '/gift/category',
name: 'GiftCategory',
component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftCategory.vue')
},
{ {
path: '/original-work', path: '/original-work',
name: 'OriginalList', name: 'OriginalList',
+55
View File
@@ -0,0 +1,55 @@
export function createGiftCategoryForm() {
return {
classificationNumber: '',
categoryCode: '',
name: '',
receiptCode: '',
representativeItem: '',
requiresDamageWaiver: false,
isActive: true
}
}
export function buildGiftCategoryRequest(form) {
return {
classificationNumber: form.classificationNumber,
categoryCode: form.categoryCode,
name: form.name,
receiptCode: form.receiptCode,
representativeItem: form.representativeItem,
requiresDamageWaiver: form.requiresDamageWaiver,
isActive: form.isActive
}
}
export function validateGiftCategory(form) {
const errors = {}
const requiredFields = [
['classificationNumber', '분류번호', 3],
['categoryCode', '카테고리 코드', 50],
['name', '카테고리명', 50],
['receiptCode', '접수 코드', 9],
['representativeItem', '대표 품목', 100]
]
for (const [key, label, maxLength] of requiredFields) {
const value = form[key]
if (typeof value !== 'string' || !value.trim()) {
errors[key] = `${label} 항목은 필수입니다.`
} else if (value.length > maxLength) {
errors[key] = `${label} 항목은 ${maxLength}자 이하로 입력해 주세요.`
}
}
if (!errors.classificationNumber && (!/^[0-9]{3}$/.test(form.classificationNumber) || form.classificationNumber.length !== 3)) {
errors.classificationNumber = '분류번호는 숫자 3자리로 입력해 주세요.'
}
if (!errors.receiptCode && /[^A-Z]/.test(form.receiptCode)) {
errors.receiptCode = '접수 코드는 영문 대문자 1~9자로 입력해 주세요.'
}
if (typeof form.requiresDamageWaiver !== 'boolean') {
errors.requiresDamageWaiver = '파손면책 동의 필요 여부를 선택해 주세요.'
}
if (typeof form.isActive !== 'boolean') {
errors.isActive = '활성 여부를 선택해 주세요.'
}
return errors
}
+344
View File
@@ -0,0 +1,344 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>선물 카테고리</v-toolbar-title>
<v-spacer />
<v-btn
color="primary"
:disabled="is_loading || is_operating"
@click="openCreate"
>
카테고리 등록
</v-btn>
</v-toolbar>
<br>
<v-container fluid>
<v-row>
<v-col>
<div class="mb-2">
총 {{ categories.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 categories"
:key="item.categoryId"
>
<td class="text-center">
{{ item.categoryId }}
</td>
<td class="text-center">
{{ item.classificationNumber }}
</td>
<td class="text-center">
{{ item.categoryCode }}
</td>
<td class="text-center">
{{ item.name }}
</td>
<td class="text-center">
{{ item.receiptCode }}
</td>
<td class="text-center">
{{ item.representativeItem }}
</td>
<td class="text-center">
{{ item.requiresDamageWaiver ? '예' : '아니오' }}
</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>
<v-btn
small
outlined
color="error"
class="ma-1"
:disabled="!item.isActive || is_loading || is_operating"
@click="requestDeactivation(item)"
>
비활성화
</v-btn>
</td>
</tr>
<tr v-if="!categories.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="getCategories"
>
다시 조회
</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="saveCategory">
<v-card-title class="headline">
{{ isEdit ? '카테고리 수정' : '카테고리 등록' }}
</v-card-title>
<v-card-text>
<v-text-field
v-for="field in fields"
:key="field.key"
v-model="form[field.key]"
:label="field.label"
:placeholder="field.placeholder"
:counter="field.maxLength"
:readonly="isEdit && field.key === 'categoryCode'"
:hint="isEdit && field.key === 'categoryCode' ? '카테고리 코드는 수정할 수 없습니다.' : ''"
:persistent-hint="isEdit && field.key === 'categoryCode'"
:disabled="is_operating"
:error-messages="form_errors[field.key]"
@input="$set(form_errors, field.key, '')"
/>
<v-switch
v-model="form.requiresDamageWaiver"
label="파손면책 동의 필요 여부"
:disabled="is_operating"
:error-messages="form_errors.requiresDamageWaiver"
@change="$set(form_errors, 'requiresDamageWaiver', '')"
/>
<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"
>
{{ isEdit ? '수정' : '등록' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
<v-dialog
v-model="show_confirm_dialog"
max-width="500px"
persistent
>
<v-card>
<v-card-title>카테고리 비활성화 확인</v-card-title>
<v-card-text v-if="pendingCategory">
<p>{{ pendingCategory.name }} 카테고리를 비활성화하시겠습니까?</p>
<p>실제 삭제되지 않고 비활성 상태로 변경됩니다.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
:disabled="is_operating"
@click="cancelDeactivation"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
:loading="is_operating"
:disabled="is_operating"
@click="deactivateCategory"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import * as api from '@/api/gift_category'
import { createGiftCategoryForm, buildGiftCategoryRequest, validateGiftCategory } from '@/utils/gift_category'
export default {
name: 'GiftCategory',
data() {
return {
headers: ['카테고리 ID', '분류번호', '카테고리 코드', '카테고리명', '접수 코드', '대표 품목', '파손면책 동의 필요 여부', '활성 여부', '관리'],
fields: [
{ key: 'classificationNumber', label: '분류번호', placeholder: '예: 100', maxLength: 3 },
{ key: 'categoryCode', label: '카테고리 코드', placeholder: '예: DOLL', maxLength: 50 },
{ key: 'name', label: '카테고리명', placeholder: '예: 인형', maxLength: 50 },
{ key: 'receiptCode', label: '접수 코드', placeholder: '예: A', maxLength: 9 },
{ key: 'representativeItem', label: '대표 품목', placeholder: '예: 피규어', maxLength: 100 }
],
categories: [],
is_loading: false,
list_error: false,
listRequestId: 0,
is_operating: false,
show_form_dialog: false,
categoryId: null,
form: createGiftCategoryForm(),
form_errors: {},
show_confirm_dialog: false,
pendingCategory: null
}
},
computed: {
isEdit() {
return this.categoryId !== null
}
},
async created() {
await this.getCategories()
},
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 getCategories() {
const requestId = ++this.listRequestId
this.is_loading = true
this.list_error = false
try {
const response = await api.getGiftCategories()
if (requestId !== this.listRequestId) return
this.checkResponse(response)
this.categories = response.data
} catch (error) {
if (requestId !== this.listRequestId) return
this.categories = []
this.list_error = true
this.notifyError(error)
} finally {
if (requestId === this.listRequestId) this.is_loading = false
}
},
openCreate() {
if (this.is_loading || this.is_operating) return
this.categoryId = null
this.form = createGiftCategoryForm()
this.form_errors = {}
this.show_form_dialog = true
},
openEdit(item) {
if (this.is_loading || this.is_operating) return
this.categoryId = item.categoryId
this.form = buildGiftCategoryRequest(item)
this.form_errors = {}
this.show_form_dialog = true
},
closeForm() {
if (this.is_operating) return
this.show_form_dialog = false
},
async saveCategory() {
if (!this.show_form_dialog || this.is_operating) return
this.form_errors = validateGiftCategory(this.form)
if (Object.keys(this.form_errors).length) return
this.is_operating = true
try {
const response = this.isEdit
? await api.updateGiftCategory(this.categoryId, this.form)
: await api.createGiftCategory(this.form)
this.checkResponse(response)
this.$dialog.notify.success(this.isEdit ? '카테고리가 수정되었습니다.' : '카테고리가 등록되었습니다.')
await this.getCategories()
this.show_form_dialog = false
} catch (error) {
this.notifyError(error)
} finally {
this.is_operating = false
}
},
requestDeactivation(item) {
if (!item.isActive || this.is_loading || this.is_operating) return
this.pendingCategory = item
this.show_confirm_dialog = true
},
cancelDeactivation() {
if (this.is_operating) return
this.show_confirm_dialog = false
this.pendingCategory = null
},
async deactivateCategory() {
if (!this.show_confirm_dialog || !this.pendingCategory || !this.pendingCategory.isActive || this.is_operating) return
this.is_operating = true
try {
const response = await api.deactivateGiftCategory(this.pendingCategory.categoryId)
this.checkResponse(response)
const index = this.categories.findIndex(item => item.categoryId === this.pendingCategory.categoryId)
if (index !== -1) this.$set(this.categories, index, response.data)
this.show_confirm_dialog = false
this.pendingCategory = null
this.$dialog.notify.success('카테고리가 비활성화되었습니다.')
} catch (error) {
this.notifyError(error)
} finally {
this.is_operating = false
}
}
}
}
</script>
+399
View File
@@ -0,0 +1,399 @@
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_category')
const { createGiftCategoryForm, buildGiftCategoryRequest, validateGiftCategory } = require('../src/utils/gift_category')
const GiftCategory = require('../src/views/Gift/GiftCategory.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 category = (overrides = {}) => ({
categoryId: 1, classificationNumber: '100', categoryCode: 'DOLL', name: '인형',
receiptCode: 'A', representativeItem: '피규어', requiresDamageWaiver: false, isActive: true,
...overrides
})
const request = (overrides = {}) => ({
classificationNumber: '100', categoryCode: 'DOLL', name: '인형', receiptCode: 'A',
representativeItem: '피규어', requiresDamageWaiver: false, isActive: true,
...overrides
})
function page() {
const errors = []
const successes = []
const vm = new Vue({ ...GiftCategory, created: undefined })
vm.$dialog = { notify: { error: message => errors.push(message), success: message => successes.push(message) } }
return { vm, errors, successes }
}
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: GET/POST/PUT/DELETE 경로, 본문 및 공통 envelope를 사용한다', async () => {
const calls = []
const response = envelope(category())
Vue.axios = Object.fromEntries(['get', 'post', 'put', 'delete'].map(method => [method, async (...args) => {
calls.push([method, ...args])
return { data: response }
}]))
assert.equal(await api.getGiftCategories(), response)
assert.equal(await api.createGiftCategory(category({ extra: '제외' })), response)
assert.equal(await api.updateGiftCategory(1, category({ extra: '제외' })), response)
assert.equal(await api.deactivateGiftCategory(1), response)
assert.deepEqual(calls, [
['get', '/api/v2/admin/gift-categories'],
['post', '/api/v2/admin/gift-categories', request()],
['put', '/api/v2/admin/gift-categories/1', request()],
['delete', '/api/v2/admin/gift-categories/1']
])
})
test('카테고리 validation: 필수/형식/길이/boolean 오류를 검사한다', () => {
const invalid = {
classificationNumber: ['', ' ', null, undefined, 100, '10', '1000', 'A10', '123', '100\n', ' 10'],
categoryCode: ['', ' \t ', null, undefined, 1, 'A'.repeat(51)],
name: ['', ' \n ', null, undefined, 1, '가'.repeat(51)],
receiptCode: ['', ' ', null, undefined, 1, 'a', 'Aa', 'A1', '가', 'A B', 'A\n', 'A'.repeat(10)],
representativeItem: ['', ' ', null, undefined, 1, '가'.repeat(101)],
requiresDamageWaiver: [null, undefined, '', 'false', 0, 1],
isActive: [null, undefined, '', 'true', 0, 1]
}
for (const [key, values] of Object.entries(invalid)) {
for (const value of values) {
const errors = validateGiftCategory(request({ [key]: value }))
assert.deepEqual(Object.keys(errors), [key], `${key}: ${String(value)}`)
assert.equal(typeof errors[key], 'string')
}
}
})
test('카테고리 validation: 최대 길이/앞자리 0/false를 보존하며 ID는 본문에서 제외한다', () => {
for (const requiresDamageWaiver of [true, false]) {
for (const isActive of [true, false]) {
const form = request({
classificationNumber: '001', categoryCode: 'A'.repeat(50), name: '가'.repeat(50),
receiptCode: 'ABCDEFGHI', representativeItem: '가'.repeat(100), requiresDamageWaiver, isActive
})
assert.deepEqual(validateGiftCategory(form), {})
assert.deepEqual(buildGiftCategoryRequest({ categoryId: 7, extra: '제외', ...form }), form)
}
}
const first = createGiftCategoryForm()
first.name = '변경'
assert.equal(createGiftCategoryForm().name, '')
})
test('카테고리 목록: 최초 GET의 response.data 배열과 필수 9개 컬럼을 표시한다', async () => {
const { vm } = page()
const calls = []
Vue.axios = { get: async (...args) => { calls.push(args); return { data: envelope([category()]) } } }
await GiftCategory.created.call(vm)
assert.deepEqual(calls, [['/api/v2/admin/gift-categories']])
assert.deepEqual(Array.from(vm.categories), [category()])
const rendered = nodes(vm._render())
assert.deepEqual(rendered.filter(node => node.tag === 'th').map(text), [
'카테고리 ID', '분류번호', '카테고리 코드', '카테고리명', '접수 코드', '대표 품목', '파손면책 동의 필요 여부', '활성 여부', '관리'
])
assert.deepEqual(rendered.filter(node => node.tag === 'td').slice(0, 8).map(text), ['1', '100', 'DOLL', '인형', 'A', '피규어', '아니오', '활성'])
button(vm, '수정').data.on.click()
assert.equal(vm.isEdit, true)
vm.closeForm()
button(vm, '카테고리 등록').data.on.click()
assert.equal(vm.isEdit, false)
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 load = vm.getCategories()
assert.equal(vm.is_loading, true)
assert.ok(text(vm._render()).includes('조회 중입니다.'))
assert.equal(button(vm, '카테고리 등록').data.attrs.disabled, true)
pending.resolve({ data: { success: false, message: '조회 실패' } })
await load
assert.deepEqual(errors, ['조회 실패'])
assert.equal(vm.is_loading, false)
assert.equal(vm.list_error, true)
Vue.axios.get = async () => ({ data: envelope([]) })
await button(vm, '다시 조회').data.on.click()
assert.equal(vm.list_error, false)
})
test('카테고리 폼: 수정 코드는 읽기 전용이며 취소는 목록을 변경하지 않고 등록은 초기화한다', () => {
const { vm } = page()
const item = category()
vm.openEdit(item)
assert.equal(vm.categoryId, 1)
assert.deepEqual(vm.form, request())
const fields = nodes(vm._render()).filter(node => node.tag === 'v-text-field')
assert.equal(fields.length, 5)
for (const field of fields) assert.equal(field.data.attrs.readonly, field.data.attrs.label === '카테고리 코드')
assert.equal(nodes(vm._render()).filter(node => node.tag === 'v-switch').length, 2)
vm.form.name = '변경'
vm.form.isActive = false
vm.closeForm()
assert.equal(item.name, '인형')
assert.equal(item.isActive, true)
vm.form_errors = { name: '오류' }
vm.openCreate()
assert.equal(vm.categoryId, null)
assert.deepEqual(vm.form, createGiftCategoryForm())
assert.deepEqual(vm.form_errors, {})
assert.equal(nodes(vm._render()).find(node => node.tag === 'v-text-field' && node.data.attrs.label === '카테고리 코드').data.attrs.readonly, false)
})
test('카테고리 폼: validation 실패 시 등록/수정 API 없이 필드 오류를 표시한다', async () => {
for (const edit of [false, true]) {
const { vm, successes } = page()
const calls = []
Vue.axios = { post: async () => { calls.push('POST') }, put: async () => { calls.push('PUT') } }
if (edit) vm.openEdit(category())
else vm.openCreate()
vm.form = request({ classificationNumber: '10', categoryCode: '', name: '', receiptCode: 'a', representativeItem: '', requiresDamageWaiver: null, isActive: null })
const form = nodes(vm._render()).find(node => node.tag === 'v-form')
await form.data.on.submit({ preventDefault() {} })
assert.deepEqual(calls, [])
assert.deepEqual(successes, [])
assert.equal(Object.keys(vm.form_errors).length, 7)
for (const field of nodes(vm._render()).filter(node => ['v-text-field', 'v-switch'].includes(node.tag))) {
assert.ok(field.data.attrs['error-messages'])
}
assert.equal(vm.show_form_dialog, true)
assert.equal(vm.is_operating, false)
}
})
test('카테고리 저장: POST/PUT 성공 후 재조회하고 모달을 닫으며 중복 제출을 차단한다', async () => {
for (const edit of [false, true]) {
const { vm, successes } = page()
const pending = deferred()
const calls = []
const payload = request({ classificationNumber: '001', requiresDamageWaiver: true, isActive: false })
const saved = category(payload)
Vue.axios = {
[edit ? 'put' : 'post']: async (...args) => { calls.push([edit ? 'PUT' : 'POST', ...args]); return { data: envelope(saved) } },
get: async (...args) => { calls.push(['GET', ...args]); return pending.promise }
}
if (edit) vm.openEdit(category())
else vm.openCreate()
vm.form = { ...payload, categoryId: 99, extra: '제외' }
const operation = vm.saveCategory()
await vm.saveCategory()
await Vue.nextTick()
assert.equal(vm.is_operating, true)
assert.equal(vm.show_form_dialog, true)
vm.closeForm()
vm.openEdit(category({ categoryId: 2 }))
vm.openCreate()
assert.equal(vm.categoryId, edit ? 1 : null)
assert.equal(vm.show_form_dialog, true)
pending.resolve({ data: envelope([saved]) })
await operation
assert.deepEqual(calls, [
[edit ? 'PUT' : 'POST', `/api/v2/admin/gift-categories${edit ? '/1' : ''}`, payload],
['GET', '/api/v2/admin/gift-categories']
])
assert.deepEqual(Array.from(vm.categories), [saved])
assert.equal(vm.show_form_dialog, false)
assert.equal(vm.is_operating, false)
assert.deepEqual(successes, [edit ? '카테고리가 수정되었습니다.' : '카테고리가 등록되었습니다.'])
await vm.saveCategory()
assert.equal(calls.length, 2)
}
})
test('카테고리 저장 실패: 공통 오류 알림과 입력/모달을 유지한다', async () => {
for (const edit of [false, true]) {
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 = { post: fail, put: fail, get: async () => { loads++ } }
if (edit) vm.openEdit(category())
else vm.openCreate()
vm.form = request()
await vm.saveCategory()
assert.deepEqual(errors, ['저장 실패'])
assert.deepEqual(successes, [])
assert.deepEqual(vm.form, request())
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 = {
post: async () => ({ data: envelope(category()) }),
get: async () => { throw new Error('조회 실패') }
}
vm.openCreate()
vm.form = request()
await vm.saveCategory()
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)
})
test('카테고리 비활성화: 확인 전/취소 후/이미 비활성이면 DELETE하지 않는다', async () => {
const { vm } = page()
let calls = 0
Vue.axios = { delete: async () => { calls++ } }
vm.categories = [category()]
button(vm, '비활성화').data.on.click()
assert.equal(vm.show_confirm_dialog, true)
assert.ok(text(vm._render()).includes('인형 카테고리를 비활성화하시겠습니까?'))
assert.equal(calls, 0)
vm.cancelDeactivation()
await vm.deactivateCategory()
assert.equal(calls, 0)
assert.equal(vm.pendingCategory, null)
vm.categories = [category({ isActive: false, requiresDamageWaiver: true })]
assert.equal(button(vm, '비활성화').data.attrs.disabled, true)
assert.ok(text(vm._render()).includes('비활성'))
assert.ok(text(vm._render()).includes('예'))
vm.requestDeactivation(vm.categories[0])
await vm.deactivateCategory()
assert.equal(calls, 0)
assert.equal(vm.show_confirm_dialog, false)
})
test('카테고리 비활성화 성공: 응답의 isActive=false를 반영하고 row는 유지한다', async () => {
const { vm, successes } = page()
const pending = deferred()
const calls = []
Vue.axios = { delete: async (...args) => { calls.push(args); return pending.promise } }
vm.categories = [category(), category({ categoryId: 2 })]
vm.requestDeactivation(vm.categories[0])
const operation = button(vm, '확인').data.on.click()
await vm.deactivateCategory()
vm.cancelDeactivation()
assert.equal(vm.show_confirm_dialog, true)
assert.equal(vm.is_operating, true)
assert.equal(button(vm, '비활성화').data.attrs.disabled, true)
pending.resolve({ data: envelope(category({ isActive: false })) })
await operation
assert.deepEqual(calls, [['/api/v2/admin/gift-categories/1']])
assert.deepEqual(Array.from(vm.categories), [category({ isActive: false }), category({ categoryId: 2 })])
assert.equal(button(vm, '비활성화').data.attrs.disabled, true)
assert.equal(vm.show_confirm_dialog, false)
assert.equal(vm.pendingCategory, null)
assert.equal(vm.is_operating, false)
assert.deepEqual(successes, ['카테고리가 비활성화되었습니다.'])
})
test('카테고리 비활성화 실패: 기존 활성 상태를 유지하고 공통 오류를 표시한다', async () => {
for (const fail of [
async () => ({ data: { success: false, message: '비활성화 실패' } }),
async () => { throw { response: { data: { message: '비활성화 실패' } } } }
]) {
const { vm, errors, successes } = page()
Vue.axios = { delete: fail }
vm.categories = [category()]
vm.requestDeactivation(vm.categories[0])
await vm.deactivateCategory()
assert.equal(vm.categories[0].isActive, true)
assert.equal(vm.show_confirm_dialog, true)
assert.equal(vm.is_operating, false)
assert.deepEqual(errors, ['비활성화 실패'])
assert.deepEqual(successes, [])
}
})
test('카테고리 목록: 늦게 도착한 이전 응답은 최신 목록을 덮어쓰지 않는다', async () => {
const { vm } = page()
const pending = deferred()
Vue.axios = { get: async () => pending.promise }
const old = vm.getCategories()
Vue.axios.get = async () => ({ data: envelope([category({ categoryId: 2 })]) })
await vm.getCategories()
pending.resolve({ data: envelope([category()]) })
await old
assert.deepEqual(Array.from(vm.categories), [category({ categoryId: 2 })])
assert.equal(vm.is_loading, false)
})
test('카테고리 메뉴: ADMIN 보완 메뉴와 서버 메뉴를 중복 없이 유지한다', async () => {
for (const role of ['ADMIN', 'CONTENT_MANAGER']) {
for (const existing of ['none', 'null', 'list', 'category', 'object']) {
const menus = []
if (existing !== 'none') {
const items = existing === 'null' ? null : existing === 'list'
? [{ title: '선물함 리스트', route: '/gift/list' }]
: existing === 'object' ? { title: '선물 카테고리', route: '/gift/category' }
: [{ title: '선물 카테고리', route: '/gift/category' }]
menus.push({ title: '선물함 관리', items })
}
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 === '선물함 관리')
assert.equal(gifts.length, role === 'ADMIN' || existing !== 'none' ? 1 : 0)
const children = gifts.length && gifts[0].items ? [].concat(gifts[0].items) : []
assert.equal(children.filter(item => item.route === '/gift/category').length, role === 'ADMIN' || ['category', 'object'].includes(existing) ? 1 : 0)
if (role === 'ADMIN') assert.equal(children.filter(item => item.route === '/gift/list').length, 1)
}
}
})
;(async () => {
for (const { name, run } of tests) {
try {
await run()
console.log(`PASS ${name}`)
} catch (error) {
process.exitCode = 1
console.error(`FAIL ${name}`, error)
}
}
})()