From 013a694b2abda5bf66a3f721c6bfdba25281797e Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 1 Oct 2026 23:39:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EB=B0=9B=EC=9D=84=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EC=A3=BC=EC=86=8C=20=EA=B2=80=EC=83=89=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/api/gift_mailbox.js | 15 ++ src/components/SideMenu.vue | 6 +- src/router/index.js | 5 + src/utils/postcode.js | 28 ++++ src/views/Gift/GiftMailbox.vue | 256 ++++++++++++++++++++++++++++ tests/gift_mailbox.test.js | 294 +++++++++++++++++++++++++++++++++ 7 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 src/api/gift_mailbox.js create mode 100644 src/utils/postcode.js create mode 100644 src/views/Gift/GiftMailbox.vue create mode 100644 tests/gift_mailbox.test.js diff --git a/package.json b/package.json index 785c621..ecfe8be 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "serve": "vue-cli-service serve --port 8888", "build": "vue-cli-service build", "build_development": "vue-cli-service build --mode development", - "test:gift": "node tests/gift.test.js && node tests/gift_category.test.js && node tests/gift_size.test.js", + "test:gift": "node tests/gift.test.js && node tests/gift_category.test.js && node tests/gift_size.test.js && node tests/gift_mailbox.test.js", "lint": "vue-cli-service lint" }, "dependencies": { diff --git a/src/api/gift_mailbox.js b/src/api/gift_mailbox.js new file mode 100644 index 0000000..f2a5c22 --- /dev/null +++ b/src/api/gift_mailbox.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; + +const baseUrl = '/api/v2/admin/gift-mailbox' + +async function getGiftMailbox() { + const response = await Vue.axios.get(baseUrl) + return response.data +} + +async function updateGiftMailbox(request) { + const response = await Vue.axios.put(baseUrl, request) + return response.data +} + +export { getGiftMailbox, updateGiftMailbox } diff --git a/src/components/SideMenu.vue b/src/components/SideMenu.vue index ae8eb45..f2575f6 100644 --- a/src/components/SideMenu.vue +++ b/src/components/SideMenu.vue @@ -118,6 +118,9 @@ export default { if (!giftMenu.items.some(item => item && item.route === '/gift/size')) { giftMenu.items.push({ title: '선물 사이즈', route: '/gift/size', items: null }) } + if (!giftMenu.items.some(item => item && item.route === '/gift/mailbox')) { + giftMenu.items.push({ title: '받을 주소', route: '/gift/mailbox', items: null }) + } } else { this.items.push({ title: '선물함 관리', @@ -126,7 +129,8 @@ export default { items: [ { title: '선물함 리스트', route: '/gift/list', items: null }, { title: '선물 카테고리', route: '/gift/category', items: null }, - { title: '선물 사이즈', route: '/gift/size', items: null } + { title: '선물 사이즈', route: '/gift/size', items: null }, + { title: '받을 주소', route: '/gift/mailbox', items: null } ] }) } diff --git a/src/router/index.js b/src/router/index.js index 02f2cb0..9b17b97 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -377,6 +377,11 @@ const routes = [ name: 'GiftSize', component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftSize.vue') }, + { + path: '/gift/mailbox', + name: 'GiftMailbox', + component: () => import(/* webpackChunkName: "gift" */ '../views/Gift/GiftMailbox.vue') + }, { path: '/original-work', name: 'OriginalList', diff --git a/src/utils/postcode.js b/src/utils/postcode.js new file mode 100644 index 0000000..eae01b8 --- /dev/null +++ b/src/utils/postcode.js @@ -0,0 +1,28 @@ +let loading + +export function loadPostcode() { + if (window.daum && window.daum.Postcode) return Promise.resolve(window.daum.Postcode) + if (loading) return loading + loading = new Promise((resolve, reject) => { + const script = document.createElement('script') + const finish = error => { + clearTimeout(timer) + script.onload = null + script.onerror = null + if (error) { + script.remove() + loading = null + reject(error) + } else { + resolve(window.daum.Postcode) + } + } + const timer = setTimeout(() => finish(new Error('주소 검색을 불러오지 못했습니다. 다시 시도해주세요.')), 15000) + script.src = 'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js' + script.async = true + script.onload = () => finish(window.daum && window.daum.Postcode ? null : new Error('주소 검색을 불러오지 못했습니다. 다시 시도해주세요.')) + script.onerror = () => finish(new Error('주소 검색을 불러오지 못했습니다. 다시 시도해주세요.')) + document.head.appendChild(script) + }) + return loading +} diff --git a/src/views/Gift/GiftMailbox.vue b/src/views/Gift/GiftMailbox.vue new file mode 100644 index 0000000..1fd1d65 --- /dev/null +++ b/src/views/Gift/GiftMailbox.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/tests/gift_mailbox.test.js b/tests/gift_mailbox.test.js new file mode 100644 index 0000000..7595a1e --- /dev/null +++ b/tests/gift_mailbox.test.js @@ -0,0 +1,294 @@ +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')) + assert.deepEqual(compiler.compile(template.content).errors, []) + module._compile(compileScript(script.content, filename), filename) + Object.assign(module.exports.default, compiler.compileToFunctions(template.content)) +} + +const GiftMailbox = require('../src/views/Gift/GiftMailbox.vue').default +const SideMenu = require('../src/components/SideMenu.vue').default +const tests = [] +const test = (name, run) => tests.push({ name, run }) +const envelope = data => ({ data: { success: true, data } }) +const saved = { name: '운영팀', phoneNumber: '010-1234-5678', address: '(01234) 서울시 도로 1, 2층, 운영실' } +const form = () => ({ name: ' 운영팀 ', phoneNumber: ' 010-1234-5678 ', zipCode: '01234', address: '서울시 도로 1', addressDetail: '2층, 운영실' }) +function page() { + const vm = new Vue({ ...GiftMailbox, created: undefined }) + const errors = [] + const successes = [] + vm.$dialog = { notify: { error: message => errors.push(message), success: message => successes.push(message) } } + return { vm, errors, successes } +} +function nodes(vnode) { + return vnode ? [vnode].concat((vnode.children || []).flatMap(nodes)) : [] +} +const text = vm => nodes(vm._render()).map(node => node.text || '').join('') + +test('초기 GET과 null 빈 폼, 안내 및 필드 표시', async () => { + const { vm } = page() + const calls = [] + Vue.axios = { get: async (...args) => { calls.push(args); return envelope(null) } } + await GiftMailbox.created.call(vm) + assert.deepEqual(calls, [['/api/v2/admin/gift-mailbox']]) + assert.ok(Object.values(vm.form).every(value => value === '')) + assert.ok(text(vm).includes('등록된 받을 주소가 없습니다.')) + assert.equal(nodes(vm._render()).filter(node => node.tag === 'v-text-field').length, 5) +}) + +test('등록된 주소 조회 및 상세주소 없는 주소 편집', async () => { + const { vm } = page() + Vue.axios = { get: async () => envelope(saved) } + await vm.getMailbox() + assert.deepEqual(vm.form, { ...form(), name: saved.name, phoneNumber: saved.phoneNumber }) + Vue.axios.get = async () => envelope({ ...saved, address: '(01234) 서울시 도로 1' }) + await vm.getMailbox() + assert.equal(vm.form.address, '서울시 도로 1') + assert.equal(vm.form.addressDetail, '') +}) + +test('필수값 공백 시 필드 오류 표시 및 PUT 차단', async () => { + const { vm } = page() + vm.loaded = true + let calls = 0 + Vue.axios = { put: async () => { calls++ } } + for (const key of ['name', 'phoneNumber', 'zipCode', 'address']) { + for (const value of ['', ' \t\n ', null]) { + vm.form = { ...form(), [key]: value } + await vm.saveMailbox() + assert.deepEqual(Object.keys(vm.form_errors), [key]) + const field = nodes(vm._render()).find(node => node.tag === 'v-text-field' && node.key === key) + assert.ok(field.data.attrs['error-messages']) + } + } + assert.equal(calls, 0) +}) + +test('생성·수정 PUT 본문, 선택값 null, 성공 알림 및 서버 응답 반영', async () => { + for (const detail of ['2층, 운영실', ' ']) { + const { vm, successes } = page() + vm.loaded = true + vm.form = { ...form(), addressDetail: detail, extra: '제외' } + const calls = [] + const result = { ...saved, name: '서버 이름', phoneNumber: '서버 연락처' } + Vue.axios = { put: async (...args) => { calls.push(args); return envelope(result) } } + await vm.saveMailbox() + await vm.saveMailbox() + assert.equal(calls.length, 2) + assert.deepEqual(calls[0], ['/api/v2/admin/gift-mailbox', { + ...form(), name: saved.name, phoneNumber: saved.phoneNumber, addressDetail: detail.trim() || null + }]) + assert.equal(vm.form.name, result.name) + assert.equal(vm.form.phoneNumber, result.phoneNumber) + assert.equal(vm.form.address, form().address) + assert.equal(vm.form.addressDetail, detail) + assert.equal(successes.length, 2) + assert.ok(text(vm).includes(saved.address)) + } +}) + +test('조회·저장 실패 알림, 입력 유지, 조회 재시도', async () => { + for (const fail of [ + async () => ({ data: { success: false, message: '실패' } }), + async () => { throw { response: { data: { message: '실패' } } } } + ]) { + const { vm, errors, successes } = page() + let puts = 0 + Vue.axios = { get: fail, put: async () => { puts++; return fail() } } + await vm.getMailbox() + await vm.saveMailbox() + assert.equal(puts, 0) + assert.equal(vm.load_error, true) + assert.ok(text(vm).includes('다시 조회')) + Vue.axios.get = async () => envelope(null) + await vm.getMailbox() + vm.form = form() + await vm.saveMailbox() + assert.deepEqual(vm.form, form()) + assert.deepEqual(errors, ['실패', '실패']) + assert.deepEqual(successes, []) + assert.equal(vm.is_operating, false) + } +}) + +test('조회 중 저장 및 중복 저장 차단', async () => { + const { vm } = page() + let resolve + let puts = 0 + Vue.axios = { + get: () => new Promise(done => { resolve = done }), + put: () => { puts++; return new Promise(done => { resolve = done }) } + } + const load = vm.getMailbox() + await vm.saveMailbox() + assert.equal(puts, 0) + resolve(envelope(null)) + await load + vm.form = form() + const save = vm.saveMailbox() + await vm.saveMailbox() + assert.equal(puts, 1) + resolve(envelope(saved)) + await save +}) + +test('주소 검색 선택: 읽기 전용 필드, 도로명·지번 선택 및 PUT 반영', async () => { + const postcode = require('../src/utils/postcode') + const original = postcode.loadPostcode + try { + for (const type of ['R', 'J']) { + const { vm } = page() + vm.loaded = true + vm.form = form() + vm.form_errors = { zipCode: '필수', address: '필수' } + vm.$refs.postcodeContainer = { innerHTML: '이전 검색' } + let options + postcode.loadPostcode = async () => function(config) { + options = config + this.embed = container => assert.equal(container, vm.$refs.postcodeContainer) + } + const fields = nodes(vm._render()).filter(node => node.tag === 'v-text-field') + for (const field of fields) { + assert.equal(field.data.attrs.readonly, ['zipCode', 'address'].includes(field.key)) + assert.equal(field.data.attrs.disabled, ['zipCode', 'address'].includes(field.key)) + } + const postcodeRow = nodes(vm._render()).find(node => node.data && node.data.class && node.data.class['mailbox-postcode-row']) + assert.ok(postcodeRow, '우편번호와 검색 버튼을 같은 행에 배치한다') + assert.ok(nodes(postcodeRow).some(node => node.key === 'zipCode')) + assert.ok(nodes(postcodeRow).some(node => node.tag === 'v-btn' && nodes(node).map(child => child.text || '').join('').trim() === '주소 검색')) + await vm.openAddressSearch() + assert.equal(vm.show_address_search, true) + options.oncomplete({ zonecode: '04524', userSelectedType: type, roadAddress: '서울 중구 세종대로 110', jibunAddress: '서울 중구 태평로1가 31' }) + assert.equal(vm.form.zipCode, '04524') + assert.equal(vm.form.address, type === 'R' ? '서울 중구 세종대로 110' : '서울 중구 태평로1가 31') + assert.equal(vm.form.addressDetail, '') + assert.equal(vm.form_errors.zipCode, '') + assert.equal(vm.form_errors.address, '') + assert.equal(vm.show_address_search, false) + vm.form.addressDetail = '3층' + let request + Vue.axios = { put: async (url, body) => { request = body; return envelope(saved) } } + await vm.saveMailbox() + assert.equal(request.zipCode, '04524') + assert.equal(request.address, vm.form.address) + assert.equal(request.addressDetail, '3층') + } + } finally { + postcode.loadPostcode = original + } +}) + +test('주소 검색 취소·화면 종료·오류 시 기존 입력 보존 및 재시도', async () => { + const postcode = require('../src/utils/postcode') + const original = postcode.loadPostcode + try { + const { vm, errors } = page() + vm.loaded = true + vm.form = form() + let resolve + let embeds = 0 + postcode.loadPostcode = () => new Promise(done => { resolve = done }) + const pending = vm.openAddressSearch() + vm.show_address_search = false + resolve(function() { this.embed = () => { embeds++ } }) + await pending + assert.equal(embeds, 0) + assert.deepEqual(vm.form, form()) + postcode.loadPostcode = async () => { throw new Error('검색 로딩 실패') } + await vm.openAddressSearch() + assert.deepEqual(errors, ['검색 로딩 실패']) + assert.equal(vm.is_postcode_loading, false) + assert.equal(vm.show_address_search, false) + assert.deepEqual(vm.form, form()) + postcode.loadPostcode = () => new Promise(done => { resolve = done }) + const retry = vm.openAddressSearch() + GiftMailbox.beforeDestroy.call(vm) + resolve(function() { this.embed = () => { embeds++ } }) + await retry + assert.equal(embeds, 0) + } finally { + postcode.loadPostcode = original + } +}) + +test('주소 검색 스크립트 단일 로드, 실패 후 재시도 및 기존 SDK 재사용', async () => { + const { loadPostcode } = require('../src/utils/postcode') + const scripts = [] + const previousWindow = global.window + const previousDocument = global.document + global.window = {} + global.document = { + createElement: () => ({ remove() { this.removed = true } }), + head: { appendChild: script => scripts.push(script) } + } + try { + const first = loadPostcode() + assert.equal(loadPostcode(), first) + assert.equal(scripts.length, 1) + assert.equal(scripts[0].src, 'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js') + scripts[0].onerror() + await assert.rejects(first, /주소 검색/) + assert.equal(scripts[0].removed, true) + const retry = loadPostcode() + assert.equal(scripts.length, 2) + global.window.daum = { Postcode: function() {} } + scripts[1].onload() + assert.equal(await retry, global.window.daum.Postcode) + assert.equal(await loadPostcode(), global.window.daum.Postcode) + assert.equal(scripts.length, 2) + } finally { + if (previousWindow === undefined) delete global.window + else global.window = previousWindow + if (previousDocument === undefined) delete global.document + else global.document = previousDocument + } +}) + +test('관리자 선물함 메뉴 연결 및 중복 방지, 라우트 등록', async () => { + for (const items of [[], [{ title: '선물함 관리', items: null }], [{ title: '선물함 관리', items: [{ title: '받을 주소', route: '/gift/mailbox' }] }]]) { + Vue.axios = { get: async () => ({ status: 200, ...envelope(items) }) } + const vm = new Vue({ ...SideMenu, created: undefined }) + vm.$store = { state: { accountStore: { role: 'ADMIN' } } } + await vm.getMenus() + const children = vm.items.find(item => item.title === '선물함 관리').items + assert.equal(children.filter(item => item.route === '/gift/mailbox').length, 1) + assert.equal(children.find(item => item.route === '/gift/mailbox').title, '받을 주소') + } + const router = fs.readFileSync(path.join(root, 'src/router/index.js'), 'utf8') + assert.match(router, /path: '\/gift\/mailbox',\s*name: 'GiftMailbox',\s*component: \(\) => import\([^\n]*'\.\.\/views\/Gift\/GiftMailbox.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) + } + } +})()