import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; const source = readFileSync(new URL('../egbim/app.js', import.meta.url), 'utf8'); const POST = '01900000-0000-7000-8000-000000000001'; const FIRST = '01900000-0000-7000-8000-000000000002'; const SECOND = '01900000-0000-7000-8000-000000000003'; const USER = { requesterId: 'owner', ssoSubject: 'owner', name: '작성자 이름' }; const COMMENTS_KEY = 'baron.qa.comments.egbim'; const comment = (extra = {}) => ({ id: FIRST, author_id: USER.requesterId, author_name: USER.name, content: '원래 댓글', created_at: '2026-10-29T04:14:00Z', ...extra }); function element() { return { innerHTML: '', textContent: '', value: '', hidden: false, disabled: false, listeners: {}, classList: { toggle() {}, add() {}, remove() {} }, addEventListener(name, callback) { this.listeners[name] = callback; }, querySelector() { return null; }, focus() {}, setSelectionRange() {} }; } async function detailPage({ comments = [], cachedComments = [], createResponse = {}, patchResponse = {}, authRefresh = false, user = USER, postOwner = USER.requesterId } = {}) { const elements = new Map([ '#detail', '[data-detail-category]', '[data-detail-title]', '[data-detail-status]', '[data-detail-date]', '[data-detail-author]', '[data-detail-company]', '[data-detail-department]', '[data-detail-content]', '[data-detail-secret]', '#comments', '#commentCount', '#commentForm', '#commentContent', '#commentError', '#commentSubmit', '[data-comment-edit-input]', '#editButton', '#deleteButton' ].map((selector) => [selector, element()])); const storage = new Map([[COMMENTS_KEY, JSON.stringify({ [POST]: cachedComments })]]); const requests = []; const context = vm.createContext({ window: { QA_CONFIG: {}, location: { origin: 'https://qa.example.test', search: '?id=' + POST }, confirm: () => true }, document: { cookie: '', addEventListener() {}, querySelector: (selector) => elements.get(selector) || null }, sessionStorage: { getItem: () => null }, localStorage: { getItem: (key) => storage.get(key) || null, setItem: (key, value) => storage.set(key, value) }, URL, URLSearchParams, console, async fetch(path, options) { requests.push({ path, ...options }); const method = options.method || 'GET'; let body; if (method === 'POST') body = createResponse; else if (method === 'PATCH') body = patchResponse; else if (method === 'DELETE') body = { ok: true }; else if (path.endsWith('/comments')) body = { comments }; else body = { id: POST, title: '문의 제목', contents: '문의 내용', requester_id: postOwner, requester_name: USER.name }; return new Response(JSON.stringify(body)); } }); vm.runInContext(source.replace(/\}\)\(\);\s*$/, ` globalThis.testPage = { initDetail, setUser(user, refresh) { resolvedAuthUser = user; if (refresh) authReady = Promise.resolve(user); } }; })();`), context); context.testPage.setUser(user, authRefresh); await context.testPage.initDetail(); await new Promise((resolve) => setImmediate(resolve)); return { elements, requests, html: () => elements.get('#comments').innerHTML, stored: () => JSON.parse(storage.get(COMMENTS_KEY))[POST], async click(action, id = FIRST, value = '') { elements.get('[data-comment-edit-input]').value = value; const button = { disabled: false, getAttribute: () => id }; await elements.get('#comments').listeners.click({ target: { closest: (selector) => selector === '[data-comment-' + action + ']' ? button : null } }); }, async create(content) { elements.get('#commentContent').value = content; await elements.get('#commentForm').listeners.submit({ preventDefault() {} }); } }; } test('initial comments show text and owner actions, and never auto-edit a comment without an ID', async () => { const page = await detailPage({ comments: [comment(), comment({ id: '', content: 'ID 없는 댓글' }), comment({ id: SECOND, author_id: 'another-user' })] }); assert.match(page.html(), /class="comment-content">ID 없는 댓글<\/div>/); assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"')); assert.match(page.html(), new RegExp('data-comment-delete="' + FIRST + '"')); assert.doesNotMatch(page.html(), /data-comment-edit-input|data-comment-save|data-comment-edit=""|data-comment-delete=""/); assert.doesNotMatch(page.html(), new RegExp('data-comment-(edit|delete)="' + SECOND + '"')); }); for (const idField of ['id', 'comment_id', 'commentId']) { test(`a ${idField} response keeps its ID and date through edit, cancel, save, and delete`, async () => { const page = await detailPage({ comments: [comment({ id: undefined, [idField]: FIRST }), comment({ id: SECOND, content: '다른 댓글' })], patchResponse: { comment: { content: '수정한 댓글' } }, authRefresh: true }); assert.doesNotMatch(page.html(), /data-comment-edit-input/); assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"')); await page.click('edit'); assert.match(page.html(), /data-comment-edit-input/); assert.match(page.html(), /class="comment-content">다른 댓글<\/div>/); assert.equal((page.html().match(/data-comment-edit-input/g) || []).length, 1); await page.click('cancel'); assert.doesNotMatch(page.html(), /data-comment-edit-input/); assert.match(page.html(), new RegExp('data-comment-delete="' + FIRST + '"')); await page.click('edit'); await page.click('save', FIRST, '수정한 댓글'); assert.doesNotMatch(page.html(), /data-comment-edit-input/); assert.match(page.html(), /class="comment-content">수정한 댓글<\/div>/); assert.match(page.html(), /2026-10-29 13:14/); assert.equal(page.stored()[0].id, FIRST); assert.equal(page.stored()[0].authorId, USER.requesterId); const patch = page.requests.find((request) => request.method === 'PATCH'); assert.equal(patch.path, '/api/feedbacks/' + POST + '/comments/' + FIRST); assert.deepEqual(JSON.parse(patch.body), { content: '수정한 댓글' }); await page.click('delete'); assert.doesNotMatch(page.html(), /수정한 댓글/); assert.match(page.html(), /다른 댓글/); assert.equal(page.requests.find((request) => request.method === 'DELETE').path, patch.path); }); } for (const createResponse of [ { id: FIRST, comment: { content: '등록한 댓글' } }, { comment: { comment_id: FIRST, content: '등록한 댓글' } }, { data: { commentId: FIRST, content: '등록한 댓글' } }, { id: FIRST } ]) { test(`a newly created comment displays owner actions using ${JSON.stringify(createResponse)}`, async () => { const page = await detailPage({ createResponse }); await page.create('등록한 댓글'); assert.match(page.html(), /class="comment-content">등록한 댓글<\/div>/); assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"')); assert.match(page.html(), new RegExp('data-comment-delete="' + FIRST + '"')); assert.doesNotMatch(page.html(), /data-comment-edit-input/); assert.equal(page.stored()[0].id, FIRST); assert.equal(page.stored()[0].author, USER.name); await page.click('edit'); await page.click('cancel'); assert.ok(page.stored()[0].date); }); } test('a cached comment without an ID remains read-only when loading the page', async () => { const page = await detailPage({ cachedComments: [comment({ id: '' })], comments: [comment({ id: '' })], authRefresh: true }); assert.match(page.html(), /class="comment-content">원래 댓글<\/div>/); assert.doesNotMatch(page.html(), /data-comment-edit|data-comment-delete|data-comment-save/); assert.equal(page.requests.some((request) => ['PATCH', 'DELETE'].includes(request.method)), false); }); test('another author post hides both post buttons while the viewer own comment keeps its actions', async () => { const page = await detailPage({ postOwner: 'another-user', comments: [comment(), comment({ id: SECOND, author_id: 'another-user' })] }); assert.equal(page.elements.get('#editButton').hidden, true); assert.equal(page.elements.get('#deleteButton').hidden, true); assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"')); assert.doesNotMatch(page.html(), new RegExp('data-comment-(edit|delete)="' + SECOND + '"')); }); test('a matching display name never shows post or comment buttons when author IDs are missing', async () => { const page = await detailPage({ postOwner: '', comments: [comment({ author_id: '' })] }); assert.equal(page.elements.get('#editButton').hidden, true); assert.equal(page.elements.get('#deleteButton').hidden, true); assert.doesNotMatch(page.html(), /data-comment-edit|data-comment-delete/); }); test('post and comment actions follow only the current primary identity and disappear without a session', async () => { const page = await detailPage({ user: { ...USER, ssoSubject: 'stale-owner', userUuid: 'stale-owner' }, comments: [comment(), comment({ id: SECOND, author_id: 'stale-owner' })] }); assert.equal(page.elements.get('#editButton').hidden, false); assert.equal(page.elements.get('#deleteButton').hidden, false); assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"')); assert.doesNotMatch(page.html(), new RegExp('data-comment-(edit|delete)="' + SECOND + '"')); const anonymous = await detailPage({ user: null, authRefresh: true, comments: [comment()] }); assert.equal(anonymous.elements.get('#editButton').hidden, true); assert.equal(anonymous.elements.get('#deleteButton').hidden, true); assert.doesNotMatch(anonymous.html(), /data-comment-edit|data-comment-delete/); });