feat(gift): 관리자 선물함 리스트 및 운영 액션 구현

This commit is contained in:
Yu Sung
2026-10-01 12:41:26 +09:00
parent b067d3547a
commit 87d9121ca9
8 changed files with 1096 additions and 0 deletions
+1
View File
@@ -7,6 +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",
"lint": "vue-cli-service lint"
},
"dependencies": {
+39
View File
@@ -0,0 +1,39 @@
import Vue from 'vue';
const baseUrl = '/api/v2/admin/gifts'
async function getGifts({ status, applicationNo, nickname, page = 0, size = 20 } = {}) {
const params = { page, size }
if (status) params.status = status
if (applicationNo) params.applicationNo = applicationNo
if (nickname) params.nickname = nickname
const response = await Vue.axios.get(baseUrl, { params })
return response.data
}
async function getGift(applicationNo) {
const response = await Vue.axios.get(`${baseUrl}/${encodeURIComponent(applicationNo)}`)
return response.data
}
async function arriveMailbox(applicationNo) {
const response = await Vue.axios.post(`${baseUrl}/${encodeURIComponent(applicationNo)}/arrive-mailbox`)
return response.data
}
async function completeInspection(applicationNo) {
const response = await Vue.axios.post(`${baseUrl}/${encodeURIComponent(applicationNo)}/complete-inspection`)
return response.data
}
async function completeDelivery(applicationNo) {
const response = await Vue.axios.post(`${baseUrl}/${encodeURIComponent(applicationNo)}/complete-delivery`)
return response.data
}
async function markUndeliverable(applicationNo, reason) {
const response = await Vue.axios.post(`${baseUrl}/${encodeURIComponent(applicationNo)}/mark-undeliverable`, { reason })
return response.data
}
export { getGifts, getGift, arriveMailbox, completeInspection, completeDelivery, markUndeliverable }
+17
View File
@@ -104,6 +104,23 @@ export default {
// ADMIN 권한 전용 추가 메뉴들
if (role === 'ADMIN') {
const giftMenu = this.items.find(m => m && m.title === '선물함 관리')
if (giftMenu) {
if (!Array.isArray(giftMenu.items)) {
this.$set(giftMenu, 'items', giftMenu.items ? [].concat(giftMenu.items) : [])
}
if (!giftMenu.items.some(item => item && item.route === '/gift/list')) {
giftMenu.items.push({ title: '선물함 리스트', route: '/gift/list', items: null })
}
} else {
this.items.push({
title: '선물함 관리',
route: null,
icon: 'mdi-gift',
items: [{ title: '선물함 리스트', route: '/gift/list', items: null }]
})
}
// '시리즈 관리' 메뉴에 '배너 등록' 하위 메뉴 추가
try {
const seriesMenu = this.items.find(m => m && m.title === '시리즈 관리')
+5
View File
@@ -362,6 +362,11 @@ const routes = [
name: 'CharacterCalculate',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCalculateList.vue')
},
{
path: '/gift/list',
name: 'GiftList',
component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftList.vue')
},
{
path: '/original-work',
name: 'OriginalList',
+23
View File
@@ -0,0 +1,23 @@
export const giftStatuses = [
{ text: '전체', value: '' },
{ text: '접수 완료', value: 'RECEIVED' },
{ text: '발송 확인', value: 'TRACKING_REGISTERED' },
{ text: '사서함 도착', value: 'ARRIVED_AT_MAILBOX' },
{ text: '검수 완료', value: 'INSPECTION_COMPLETED' },
{ text: '전달 완료', value: 'DELIVERED' },
{ text: '전달 불가', value: 'UNDELIVERABLE' },
{ text: '신청 취소', value: 'CANCELED' }
]
export const giftActions = [
{ value: 'ARRIVE_MAILBOX', label: '사서함 도착 처리', method: 'arriveMailbox' },
{ value: 'COMPLETE_INSPECTION', label: '검수 완료 처리', method: 'completeInspection' },
{ value: 'COMPLETE_DELIVERY', label: '전달 완료 처리', method: 'completeDelivery' },
{ value: 'MARK_UNDELIVERABLE', label: '전달 불가 처리', method: 'markUndeliverable' }
]
export function validateUndeliverableReason(reason) {
if (typeof reason !== 'string' || !reason.trim()) return '전달 불가 사유를 입력해 주세요.'
if (reason.length > 255) return '전달 불가 사유는 255자 이내로 입력해 주세요.'
return true
}
+33
View File
@@ -0,0 +1,33 @@
<template>
<span>
<v-btn
v-for="action in visibleActions"
:key="action.value"
:color="action.value === 'MARK_UNDELIVERABLE' ? 'error' : 'primary'"
:disabled="disabled"
class="ma-1"
small
outlined
@click.stop="$emit('select', action.value)"
>
{{ action.label }}
</v-btn>
</span>
</template>
<script>
import { giftActions } from '@/utils/gift'
export default {
name: 'GiftActions',
props: {
actions: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false }
},
computed: {
visibleActions() {
return giftActions.filter(action => this.actions.includes(action.value))
}
}
}
</script>
+562
View File
@@ -0,0 +1,562 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>선물함 리스트</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container fluid>
<v-form @submit.prevent="search">
<v-row align="center">
<v-col
cols="12"
md="2"
>
<v-select
v-model="filters.status"
:items="giftStatuses"
:disabled="is_loading || is_operating"
label="상태"
/>
</v-col>
<v-col
cols="12"
md="3"
>
<v-text-field
v-model="filters.applicationNo"
:disabled="is_loading || is_operating"
label="신청번호"
clearable
/>
</v-col>
<v-col
cols="12"
md="4"
>
<v-text-field
v-model="filters.nickname"
:disabled="is_loading || is_operating"
label="발송인 또는 수취인 닉네임"
clearable
/>
</v-col>
<v-col
cols="12"
md="3"
>
<v-btn
type="submit"
color="#3bb9f1"
:disabled="is_loading || is_operating"
dark
>
검색
</v-btn>
<v-btn
class="ml-2"
:disabled="is_loading || is_operating"
@click="reset"
>
초기화
</v-btn>
</v-col>
</v-row>
</v-form>
<v-row>
<v-col>
<div class="mb-2">
총 {{ totalCount }}건
</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 gifts"
:key="item.applicationNo"
>
<td class="text-center">
{{ item.applicationNo }}
</td>
<td class="text-center">
{{ item.senderNickname }}
</td>
<td class="text-center">
{{ item.recipientNickname }}
</td>
<td class="text-center">
{{ item.sizeName }} / {{ item.sizeCode }}
</td>
<td class="text-center">
{{ item.categoryName }}
</td>
<td class="text-center">
{{ item.classificationNumber }}
</td>
<td class="text-center">
{{ item.courierCompanyName || '-' }}
</td>
<td class="text-center">
{{ item.trackingNumber || '-' }}
</td>
<td class="text-center">
{{ item.statusName }}
</td>
<td class="text-center">
<v-btn
class="ma-1"
small
:disabled="is_operating || is_loading"
@click="openDetail(item.applicationNo)"
>
상세
</v-btn>
<gift-actions
:actions="item.availableActions"
:disabled="is_operating || is_loading"
@select="requestAction(item, $event)"
/>
</td>
</tr>
<tr v-if="!gifts.length">
<td
:colspan="headers.length"
class="text-center"
>
{{ is_loading ? '조회 중입니다.' : list_error ? '목록을 불러오지 못했습니다. 다시 검색해 주세요.' : '조회된 선물 신청이 없습니다.' }}
</td>
</tr>
</tbody>
</v-simple-table>
</v-col>
</v-row>
<v-row class="text-center">
<v-col>
<v-pagination
:value="page"
:length="total_page"
:disabled="is_loading || is_operating"
:total-visible="10"
circle
@input="changePage"
/>
</v-col>
</v-row>
</v-container>
<v-dialog
v-model="show_detail_dialog"
max-width="1000px"
persistent
scrollable
>
<v-card>
<v-card-title>선물 상세</v-card-title>
<v-progress-linear
v-if="is_detail_loading"
indeterminate
/>
<v-card-text>
<template v-if="detail">
<div class="mb-4">
신청번호: {{ detail.applicationNo }} / 현재 상태: {{ detail.statusName }}
</div>
<v-row>
<v-col
v-for="member in memberSections"
:key="member.key"
cols="12"
md="6"
>
<h3 class="mb-2">
{{ member.title }}
</h3>
<v-simple-table>
<tbody>
<tr
v-for="field in memberFields"
:key="field.key"
>
<th>{{ field.label }}</th>
<td>{{ detail[member.key][field.key] || (member.key === 'recipientInfo' && field.key !== 'nickname' ? '미입력' : '-') }}</td>
</tr>
</tbody>
</v-simple-table>
</v-col>
<v-col
cols="12"
md="6"
>
<h3 class="mb-2">
상품 정보
</h3>
<v-simple-table>
<tbody>
<tr><th>사이즈</th><td>{{ detail.productInfo.sizeName }} / {{ detail.productInfo.sizeCode }}</td></tr>
<tr><th>카테고리</th><td>{{ detail.productInfo.categoryName }}</td></tr>
<tr><th>분류번호</th><td>{{ detail.productInfo.classificationNumber }}</td></tr>
</tbody>
</v-simple-table>
</v-col>
<v-col
cols="12"
md="6"
>
<h3 class="mb-2">
입고 배송 정보
</h3>
<v-simple-table>
<tbody>
<tr><th>택배사</th><td>{{ detail.inboundDeliveryInfo.courierCompanyName || '-' }}</td></tr>
<tr><th>운송장번호</th><td>{{ detail.inboundDeliveryInfo.trackingNumber || '-' }}</td></tr>
</tbody>
</v-simple-table>
</v-col>
</v-row>
<div class="mt-4">
<h3 class="mb-2">
가능한 액션
</h3>
<gift-actions
:actions="detail.availableActions"
:disabled="is_operating || is_detail_loading || is_loading"
@select="requestAction(detail, $event)"
/>
<span v-if="!detail.availableActions.length">현재 가능한 액션이 없습니다.</span>
</div>
</template>
<div v-else-if="!is_detail_loading">
상세 정보를 불러오지 못했습니다.
<v-btn
text
color="primary"
@click="getDetail(detailApplicationNo)"
>
다시 시도
</v-btn>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
:disabled="is_operating"
@click="closeDetail"
>
닫기
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="show_reason_dialog"
max-width="500px"
persistent
>
<v-card>
<v-card-title>전달 불가 처리</v-card-title>
<v-card-text>
<p>신청번호: {{ pendingAction ? pendingAction.applicationNo : '' }}</p>
<v-form @submit.prevent="confirmReason">
<v-textarea
v-model="reason"
label="전달 불가 사유"
:rules="[validateUndeliverableReason]"
:error-messages="reason_error"
:disabled="is_operating"
counter="255"
maxlength="255"
auto-grow
rows="3"
@input="reason_error = ''"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
:disabled="is_operating"
@click="cancelAction"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
:disabled="is_operating"
@click="confirmReason"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="show_confirm_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-title>상태 변경 확인</v-card-title>
<v-card-text v-if="pendingAction">
<p>신청번호: {{ pendingAction.applicationNo }}</p>
<p>{{ pendingAction.label }}하시겠습니까?</p>
<p
v-if="pendingAction.value === 'MARK_UNDELIVERABLE'"
class="gift-reason"
>
사유: {{ reason.trim() }}
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
:disabled="is_operating"
@click="cancelConfirmation"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
:loading="is_operating"
:disabled="is_operating"
@click="executeAction"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import * as api from '@/api/gift'
import { giftActions, giftStatuses, validateUndeliverableReason } from '@/utils/gift'
import GiftActions from './GiftActions.vue'
export default {
name: 'GiftList',
components: { GiftActions },
data() {
return {
giftStatuses,
headers: ['신청번호', '발송인 닉네임', '수취인 닉네임', '사이즈', '카테고리', '분류번호', '택배사', '운송장번호', '상태', '액션'],
memberSections: [{ key: 'senderInfo', title: '발송인 정보' }, { key: 'recipientInfo', title: '수취인 정보' }],
memberFields: [{ key: 'nickname', label: '닉네임' }, { key: 'name', label: '이름' }, { key: 'phoneNumber', label: '전화번호' }, { key: 'address', label: '주소' }],
filters: { status: '', applicationNo: '', nickname: '' },
appliedFilters: { status: '', applicationNo: '', nickname: '' },
gifts: [],
page: 1,
size: 20,
totalCount: 0,
hasNext: false,
is_loading: false,
list_error: false,
listRequestId: 0,
detail: null,
detailApplicationNo: '',
detailRequestId: 0,
is_detail_loading: false,
show_detail_dialog: false,
is_operating: false,
pendingAction: null,
show_reason_dialog: false,
show_confirm_dialog: false,
reason: '',
reason_error: ''
}
},
computed: {
total_page() {
const count = Math.max(1, Math.ceil(this.totalCount / this.size))
return this.hasNext ? Math.max(count, this.page + 1) : Math.max(1, this.page)
}
},
async created() {
await this.getGifts()
},
beforeDestroy() {
this.listRequestId++
this.detailRequestId++
},
methods: {
validateUndeliverableReason,
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 getGifts(page = this.page) {
const requestId = ++this.listRequestId
this.is_loading = true
this.list_error = false
try {
const response = await api.getGifts({ ...this.appliedFilters, page: page - 1, size: this.size })
if (requestId !== this.listRequestId) return
this.checkResponse(response)
this.gifts = response.data.items
this.page = response.data.page + 1
this.size = response.data.size
this.totalCount = response.data.totalCount
this.hasNext = response.data.hasNext
} catch (error) {
if (requestId !== this.listRequestId) return
this.gifts = []
this.totalCount = 0
this.hasNext = false
this.list_error = true
this.notifyError(error)
} finally {
if (requestId === this.listRequestId) this.is_loading = false
}
},
async search() {
if (this.is_loading || this.is_operating) return
this.appliedFilters = {
status: this.filters.status || '',
applicationNo: (this.filters.applicationNo || '').trim(),
nickname: (this.filters.nickname || '').trim()
}
await this.getGifts(1)
},
async reset() {
if (this.is_loading || this.is_operating) return
this.filters = { status: '', applicationNo: '', nickname: '' }
await this.search()
},
async changePage(page) {
if (this.is_loading || this.is_operating) return
await this.getGifts(page)
},
async openDetail(applicationNo) {
if (this.is_operating) return
this.detail = null
this.show_detail_dialog = true
await this.getDetail(applicationNo)
},
async getDetail(applicationNo) {
const requestId = ++this.detailRequestId
this.detailApplicationNo = applicationNo
this.is_detail_loading = true
try {
const response = await api.getGift(applicationNo)
if (requestId !== this.detailRequestId) return
this.checkResponse(response)
this.detail = response.data
} catch (error) {
if (requestId !== this.detailRequestId) return
this.detail = null
this.notifyError(error)
} finally {
if (requestId === this.detailRequestId) this.is_detail_loading = false
}
},
closeDetail() {
if (this.is_operating) return
this.detailRequestId++
this.show_detail_dialog = false
this.is_detail_loading = false
this.detail = null
this.detailApplicationNo = ''
},
requestAction(item, value) {
if (this.is_operating || this.is_loading || this.is_detail_loading || this.pendingAction) return
const action = giftActions.find(action => action.value === value)
if (!action || !item.availableActions.includes(value)) return
this.pendingAction = { ...action, applicationNo: item.applicationNo }
this.reason = ''
this.reason_error = ''
if (value === 'MARK_UNDELIVERABLE') {
this.show_reason_dialog = true
} else {
this.show_confirm_dialog = true
}
},
confirmReason() {
if (this.is_operating || !this.pendingAction || this.pendingAction.value !== 'MARK_UNDELIVERABLE') return
const validation = validateUndeliverableReason(this.reason)
this.reason_error = validation === true ? '' : validation
if (validation === true) this.show_confirm_dialog = true
},
cancelConfirmation() {
if (this.is_operating) return
if (this.pendingAction && this.pendingAction.value === 'MARK_UNDELIVERABLE') {
this.show_confirm_dialog = false
} else {
this.cancelAction()
}
},
cancelAction() {
if (this.is_operating) return
this.show_confirm_dialog = false
this.show_reason_dialog = false
this.pendingAction = null
this.reason = ''
this.reason_error = ''
},
async executeAction() {
if (this.is_operating || !this.pendingAction || !this.show_confirm_dialog) return
const action = this.pendingAction
if (action.value === 'MARK_UNDELIVERABLE' && validateUndeliverableReason(this.reason) !== true) {
this.show_confirm_dialog = false
this.confirmReason()
return
}
this.is_operating = true
try {
const response = action.value === 'MARK_UNDELIVERABLE'
? await api.markUndeliverable(action.applicationNo, this.reason.trim())
: await api[action.method](action.applicationNo)
this.checkResponse(response)
this.show_confirm_dialog = false
this.show_reason_dialog = false
this.pendingAction = null
this.reason = ''
this.$dialog.notify.success(`${action.label}되었습니다.`)
const refreshes = [this.getGifts()]
if (this.show_detail_dialog) refreshes.push(this.getDetail(this.detail ? this.detail.applicationNo : this.detailApplicationNo))
await Promise.all(refreshes)
} catch (error) {
this.notifyError(error)
} finally {
this.is_operating = false
}
}
}
}
</script>
<style scoped>
.gift-reason {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
+416
View File
@@ -0,0 +1,416 @@
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')
const { giftActions, giftStatuses, validateUndeliverableReason } = require('../src/utils/gift')
const GiftActions = require('../src/views/Gift/GiftActions.vue').default
const GiftList = require('../src/views/Gift/GiftList.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 list = (overrides = {}) => ({ items: [], page: 0, size: 20, totalCount: 0, hasNext: false, ...overrides })
const detail = (overrides = {}) => ({
applicationNo: 'G-001', status: 'TRACKING_REGISTERED', statusName: '발송 확인',
availableActions: ['ARRIVE_MAILBOX', 'MARK_UNDELIVERABLE'],
senderInfo: { nickname: '발송인', name: '이름', phoneNumber: '010', address: '주소' },
recipientInfo: { nickname: '수취인', name: '', phoneNumber: '', address: '' },
productInfo: { sizeName: '소형', sizeCode: 'S', categoryName: '문구', classificationNumber: 'C1' },
inboundDeliveryInfo: { courierCompanyName: null, trackingNumber: null },
...overrides
})
function page() {
const errors = []
const successes = []
const vm = new Vue({ ...GiftList, 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))
}
test('API: 기본 페이지와 조합 필터를 query로 전달하고 envelope를 반환한다', async () => {
const calls = []
const response = envelope(list())
Vue.axios = { get: async (...args) => { calls.push(args); return { data: response } } }
assert.equal(await api.getGifts(), response)
assert.deepEqual(calls[0], ['/api/v2/admin/gifts', { params: { page: 0, size: 20 } }])
await api.getGifts({ status: 'ARRIVED_AT_MAILBOX', applicationNo: 'G-01', nickname: '닉 네임', page: 2, size: 10 })
assert.deepEqual(calls[1][1].params, { status: 'ARRIVED_AT_MAILBOX', applicationNo: 'G-01', nickname: '닉 네임', page: 2, size: 10 })
await api.getGifts({ status: '', applicationNo: '', nickname: '' })
assert.deepEqual(calls[2][1].params, { page: 0, size: 20 })
})
test('API: 상세 및 네 가지 POST 경로/본문을 정확하게 전달한다', async () => {
const calls = []
Vue.axios = {
get: async (...args) => { calls.push(['GET', ...args]); return { data: envelope(detail()) } },
post: async (...args) => { calls.push(['POST', ...args]); return { data: envelope({ status: 'ARRIVED_AT_MAILBOX' }) } }
}
await api.getGift('G/001')
await api.arriveMailbox('G/001')
await api.completeInspection('G/001')
await api.completeDelivery('G/001')
await api.markUndeliverable('G/001', '파손')
assert.deepEqual(calls, [
['GET', '/api/v2/admin/gifts/G%2F001'],
['POST', '/api/v2/admin/gifts/G%2F001/arrive-mailbox'],
['POST', '/api/v2/admin/gifts/G%2F001/complete-inspection'],
['POST', '/api/v2/admin/gifts/G%2F001/complete-delivery'],
['POST', '/api/v2/admin/gifts/G%2F001/mark-undeliverable', { reason: '파손' }]
])
})
test('액션: 서버 availableActions에 있는 버튼만 실제 템플릿에서 노출한다', () => {
assert.deepEqual(giftActions.map(action => [action.value, action.label]), [
['ARRIVE_MAILBOX', '사서함 도착 처리'], ['COMPLETE_INSPECTION', '검수 완료 처리'],
['COMPLETE_DELIVERY', '전달 완료 처리'], ['MARK_UNDELIVERABLE', '전달 불가 처리']
])
const scenarios = [[], ['ARRIVE_MAILBOX', 'MARK_UNDELIVERABLE'], ['COMPLETE_INSPECTION', 'MARK_UNDELIVERABLE'], ['COMPLETE_DELIVERY', 'MARK_UNDELIVERABLE'], [], [], []]
for (const actions of scenarios.concat([['UNKNOWN'], ['MARK_UNDELIVERABLE']])) {
const vm = new Vue({ ...GiftActions, propsData: { actions } })
const buttons = nodes(vm._render()).filter(node => node.tag === 'v-btn')
assert.deepEqual(buttons.map(button => nodes(button).map(node => node.text || '').join('').trim()), giftActions.filter(action => actions.includes(action.value)).map(action => action.label))
}
})
test('필터: 전체와 일곱 가지 상태를 한국어로 제공한다', () => {
assert.deepEqual(giftStatuses.map(status => [status.value, status.text]), [
['', '전체'], ['RECEIVED', '접수 완료'], ['TRACKING_REGISTERED', '발송 확인'],
['ARRIVED_AT_MAILBOX', '사서함 도착'], ['INSPECTION_COMPLETED', '검수 완료'],
['DELIVERED', '전달 완료'], ['UNDELIVERABLE', '전달 불가'], ['CANCELED', '신청 취소']
])
})
test('액션 버튼: 선택 이벤트와 중복 실행 방지 속성을 연결한다', () => {
const vm = new Vue({ ...GiftActions, propsData: { actions: ['ARRIVE_MAILBOX'], disabled: true } })
const button = nodes(vm._render()).find(node => node.tag === 'v-btn')
assert.equal(button.data.attrs.disabled, true)
let selected
let stopped = false
vm.$on('select', action => { selected = action })
button.data.on.click({ stopPropagation: () => { stopped = true } })
assert.equal(selected, 'ARRIVE_MAILBOX')
assert.equal(stopped, true)
})
test('전달 불가 사유: 빈 값/공백/256자는 거부하고 255자는 허용한다', () => {
for (const value of ['', ' \n\t ', null, undefined, '가'.repeat(256)]) {
assert.notEqual(validateUndeliverableReason(value), true)
}
assert.equal(validateUndeliverableReason('가'.repeat(255)), true)
assert.equal(validateUndeliverableReason(' 파손 '), true)
})
test('목록: 검색은 첫 페이지, 페이지 이동은 적용된 필터와 서버 size를 유지한다', async () => {
const { vm } = page()
const calls = []
Vue.axios = { get: async (url, config) => {
calls.push(config.params)
return { data: envelope(list({ page: config.params.page, size: 10, totalCount: 25, hasNext: config.params.page < 2 })) }
} }
vm.filters = { status: 'RECEIVED', applicationNo: ' G-01 ', nickname: ' 닉네임 ' }
vm.page = 3
await vm.search()
assert.deepEqual(calls[0], { status: 'RECEIVED', applicationNo: 'G-01', nickname: '닉네임', page: 0, size: 20 })
assert.equal(vm.page, 1)
assert.equal(vm.total_page, 3)
vm.filters.nickname = '아직 검색하지 않은 값'
await vm.changePage(2)
assert.deepEqual(calls[1], { status: 'RECEIVED', applicationNo: 'G-01', nickname: '닉네임', page: 1, size: 10 })
await vm.reset()
assert.deepEqual(calls[2], { page: 0, size: 10 })
assert.deepEqual(vm.filters, { status: '', applicationNo: '', nickname: '' })
})
test('페이지네이션: 빈 결과 및 마지막 페이지의 hasNext를 반영한다', () => {
const { vm } = page()
assert.equal(vm.total_page, 1)
vm.totalCount = 100
vm.page = 2
vm.hasNext = false
assert.equal(vm.total_page, 2)
vm.hasNext = true
assert.equal(vm.total_page, 5)
})
test('목록: 열과 빈 값을 표시하고 상세 버튼과 액션 이벤트를 연결한다', async () => {
const { vm } = page()
const item = {
applicationNo: 'G-001', senderNickname: '발송인', recipientNickname: '수취인',
sizeName: '소형', sizeCode: 'S', categoryName: '문구', classificationNumber: 'C1',
courierCompanyName: null, trackingNumber: null, statusName: '발송 확인', availableActions: ['ARRIVE_MAILBOX']
}
vm.gifts = [item]
const rendered = nodes(vm._render())
const cells = rendered.filter(node => node.tag === 'td').map(node => nodes(node).map(child => child.text || '').join('').trim())
assert.deepEqual(cells.slice(0, 9), ['G-001', '발송인', '수취인', '소형 / S', '문구', 'C1', '-', '-', '발송 확인'])
assert.equal(rendered.filter(node => node.tag === 'th').length, 10)
const actions = rendered.find(node => node.componentOptions && node.componentOptions.Ctor.options.name === 'GiftActions')
assert.deepEqual(Array.from(actions.componentOptions.propsData.actions), ['ARRIVE_MAILBOX'])
actions.componentOptions.listeners.select('ARRIVE_MAILBOX')
assert.equal(vm.pendingAction.applicationNo, 'G-001')
vm.cancelAction()
Vue.axios = { get: async url => {
assert.equal(url, '/api/v2/admin/gifts/G-001')
return { data: envelope(detail()) }
} }
const button = rendered.find(node => node.tag === 'v-btn' && nodes(node).some(child => (child.text || '').trim() === '상세'))
await button.data.on.click()
assert.equal(vm.show_detail_dialog, true)
assert.equal(vm.detail.applicationNo, 'G-001')
})
test('상세: 수취인 미입력과 배송정보 null을 표시하고 모든 정보를 제공한다', async () => {
const { vm } = page()
Vue.axios = { get: async () => ({ data: envelope(detail()) }) }
await vm.openDetail('G-001')
const text = nodes(vm._render()).map(node => node.text || '').join(' ')
for (const label of ['발송인 정보', '수취인 정보', '상품 정보', '입고 배송 정보', '미입력', '발송 확인', 'G-001', '소형', '문구', 'C1']) assert.ok(text.includes(label), label)
assert.ok(text.includes('-'))
})
test('액션: 확인 전 POST하지 않고 취소/미허용 액션은 실행하지 않는다', async () => {
const { vm } = page()
let posts = 0
Vue.axios = { post: async () => { posts++; return { data: envelope({}) } } }
vm.requestAction(detail(), 'COMPLETE_DELIVERY')
assert.equal(vm.show_confirm_dialog, false)
vm.requestAction(detail(), 'ARRIVE_MAILBOX')
assert.equal(vm.show_confirm_dialog, true)
assert.equal(posts, 0)
vm.cancelAction()
await vm.executeAction()
assert.equal(posts, 0)
})
test('전달 불가: 사유 검증 후 확인창을 열고 trim된 사유만 전송한다', async () => {
const { vm } = page()
const posts = []
Vue.axios = {
post: async (...args) => { posts.push(args); return { data: envelope({ status: 'UNDELIVERABLE' }) } },
get: async () => ({ data: envelope(list()) })
}
vm.requestAction(detail(), 'MARK_UNDELIVERABLE')
assert.equal(vm.show_reason_dialog, true)
assert.equal(vm.show_confirm_dialog, false)
for (const reason of ['', ' \n ', '가'.repeat(256)]) {
vm.reason = reason
vm.confirmReason()
await vm.executeAction()
assert.equal(vm.show_confirm_dialog, false)
assert.equal(posts.length, 0)
}
vm.reason = ' 파손 '
vm.confirmReason()
assert.equal(vm.show_confirm_dialog, true)
assert.equal(posts.length, 0)
await vm.executeAction()
assert.deepEqual(posts, [['/api/v2/admin/gifts/G-001/mark-undeliverable', { reason: '파손' }]])
})
test('전달 불가: 확인 취소 시 사유를 유지하고 입력 모달 취소 시 초기화한다', () => {
const { vm } = page()
vm.requestAction(detail(), 'MARK_UNDELIVERABLE')
vm.reason = '파손'
vm.confirmReason()
vm.cancelConfirmation()
assert.equal(vm.show_confirm_dialog, false)
assert.equal(vm.show_reason_dialog, true)
assert.equal(vm.reason, '파손')
vm.cancelAction()
assert.equal(vm.show_reason_dialog, false)
assert.equal(vm.pendingAction, null)
assert.equal(vm.reason, '')
})
test('액션: 각 무본문 액션을 확인 후 실행하며 닫힌 상세는 조회하지 않는다', async () => {
const scenarios = [
['ARRIVE_MAILBOX', 'arrive-mailbox'], ['COMPLETE_INSPECTION', 'complete-inspection'], ['COMPLETE_DELIVERY', 'complete-delivery']
]
for (const [action, endpoint] of scenarios) {
const { vm, successes } = page()
const calls = []
Vue.axios = {
post: async (...args) => { calls.push(args); return { data: envelope({}) } },
get: async (url, config) => {
assert.equal(url, '/api/v2/admin/gifts')
assert.equal(config.params.page, 0)
return { data: envelope(list()) }
}
}
vm.requestAction(detail({ availableActions: [action] }), action)
await vm.executeAction()
assert.deepEqual(calls, [[`/api/v2/admin/gifts/G-001/${endpoint}`]])
assert.equal(successes.length, 1)
}
})
test('성공: 중복 실행을 차단하고 목록과 열린 상세를 재조회한다', async () => {
const { vm, successes } = page()
const pending = deferred()
const calls = []
Vue.axios = {
post: async (...args) => { calls.push(['POST', ...args]); await pending.promise; return { data: envelope({ status: 'ARRIVED_AT_MAILBOX' }) } },
get: async (url, config) => {
calls.push(['GET', url, config])
return { data: envelope(config ? list({ page: 2, totalCount: 60 }) : detail({ status: 'ARRIVED_AT_MAILBOX', availableActions: ['COMPLETE_INSPECTION', 'MARK_UNDELIVERABLE'] })) }
}
}
vm.appliedFilters = { status: 'TRACKING_REGISTERED', applicationNo: '', nickname: '발송인' }
vm.page = 3
vm.detail = detail()
vm.show_detail_dialog = true
vm.requestAction(vm.detail, 'ARRIVE_MAILBOX')
const operation = vm.executeAction()
await vm.executeAction()
pending.resolve()
await operation
assert.equal(calls.filter(call => call[0] === 'POST').length, 1)
assert.deepEqual(calls.find(call => call[1] === '/api/v2/admin/gifts')[2].params, { status: 'TRACKING_REGISTERED', nickname: '발송인', page: 2, size: 20 })
assert.ok(calls.some(call => call[1] === '/api/v2/admin/gifts/G-001'))
assert.equal(vm.detail.status, 'ARRIVED_AT_MAILBOX')
assert.equal(successes.length, 1)
assert.equal(vm.is_operating, false)
assert.equal(vm.show_confirm_dialog, false)
})
test('실패: envelope 실패/HTTP 오류에 공통 오류 알림을 표시한다', async () => {
const { vm, errors, successes } = page()
Vue.axios = { get: async () => ({ data: { success: false, message: '조회 실패' } }) }
await vm.getGifts()
assert.ok(errors.includes('조회 실패'))
for (const fail of [async () => ({ data: { success: false, message: '상태 변경 실패' } }), async () => { throw { response: { data: { message: '권한 없음' } } } }]) {
Vue.axios.post = fail
vm.requestAction(detail(), 'ARRIVE_MAILBOX')
await vm.executeAction()
assert.equal(vm.is_operating, false)
vm.cancelAction()
}
assert.deepEqual(errors, ['조회 실패', '상태 변경 실패', '권한 없음'])
assert.equal(successes.length, 0)
})
test('성공 후 재조회 실패: 오래된 액션을 제거하고 성공/조회 실패를 구분한다', async () => {
const { vm, errors, successes } = page()
vm.detail = detail()
vm.show_detail_dialog = true
Vue.axios = {
post: async () => ({ data: envelope({}) }),
get: async () => { throw new Error('조회 실패') }
}
vm.requestAction(vm.detail, 'ARRIVE_MAILBOX')
await vm.executeAction()
assert.equal(successes.length, 1)
assert.equal(errors.length, 2)
assert.equal(vm.detail, null)
assert.equal(vm.gifts.length, 0)
assert.equal(vm.list_error, true)
assert.equal(vm.is_operating, false)
Vue.axios.get = async () => ({ data: envelope(detail({ availableActions: ['COMPLETE_INSPECTION'] })) })
await vm.getDetail(vm.detailApplicationNo)
assert.deepEqual(Array.from(vm.detail.availableActions), ['COMPLETE_INSPECTION'])
})
test('목록: 늦게 도착한 응답이 최신 페이지를 덮어쓰지 않는다', async () => {
const { vm } = page()
const pending = deferred()
Vue.axios = { get: async (url, config) => config.params.page === 0 ? pending.promise : { data: envelope(list({ page: 1, totalCount: 40 })) } }
const old = vm.getGifts(1)
await vm.getGifts(2)
pending.resolve({ data: envelope(list({ page: 0, totalCount: 1 })) })
await old
assert.equal(vm.page, 2)
assert.equal(vm.totalCount, 40)
assert.equal(vm.is_loading, false)
})
test('상세: 뒤늦은 이전 응답이 새로 선택한 선물을 덮어쓰지 않는다', async () => {
const { vm } = page()
const pending = deferred()
Vue.axios = { get: async url => url.endsWith('/OLD') ? pending.promise : { data: envelope(detail({ applicationNo: 'NEW' })) } }
const old = vm.openDetail('OLD')
await vm.openDetail('NEW')
pending.resolve({ data: envelope(detail({ applicationNo: 'OLD' })) })
await old
assert.equal(vm.detail.applicationNo, 'NEW')
})
test('상세: 조회 중 닫은 모달에 늦은 응답을 반영하지 않는다', async () => {
const { vm } = page()
const pending = deferred()
Vue.axios = { get: async () => pending.promise }
const request = vm.openDetail('G-001')
vm.closeDetail()
pending.resolve({ data: envelope(detail()) })
await request
assert.equal(vm.show_detail_dialog, false)
assert.equal(vm.detail, null)
assert.equal(vm.is_detail_loading, false)
})
test('메뉴: ADMIN에 선물함 리스트를 추가하고 기존 서버 메뉴와 중복되지 않는다', async () => {
for (const role of ['ADMIN', 'CONTENT_MANAGER']) {
for (const existing of [false, true]) {
const menus = [{ title: '콘텐츠 리스트', route: '/content/list' }]
if (existing) menus.push({ title: '선물함 관리', items: [{ title: '선물함 리스트', route: '/gift/list' }] })
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 ? 1 : 0)
if (gifts.length) assert.equal(gifts[0].items.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)
}
}
})()