417 lines
19 KiB
JavaScript
417 lines
19 KiB
JavaScript
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)
|
|
}
|
|
}
|
|
})()
|