diff --git a/egbim/app.js b/egbim/app.js index 29b2c42..1fd37e1 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -757,10 +757,10 @@ comment = comment || {}; const rawAttachments = comment.attachments || comment.images || comment.comment_attachments || comment.commentAttachments || []; return { - id: comment.id || '', + id: String(comment.id || comment.comment_id || comment.commentId || '').trim(), authorId: comment.author_id || comment.authorId || comment.requester_id || comment.requesterId || '', author: comment.author_name || comment.authorName || comment.author?.name || comment.author || '작성자', - date: formatKoreanDateTime(comment.created_at || comment.createdAt || comment.updated_at || '', true), + date: comment.date || formatKoreanDateTime(comment.created_at || comment.createdAt || comment.updated_at || '', true), content: comment.content || comment.body || '', attachments: (Array.isArray(rawAttachments) ? rawAttachments : [rawAttachments]).map(normalizeAttachment) }; @@ -775,7 +775,7 @@ }).join(''); } - let editingCommentId = ''; + let editingCommentId = null; function isCommentOwner(comment, user) { const authorId = String(comment.authorId || '').trim().toLowerCase(); const userIds = [user && user.requesterId, user && user.ssoSubject, user && user.userUuid].map(function (id) { return String(id || '').trim().toLowerCase(); }).filter(Boolean); @@ -787,8 +787,8 @@ qs('#commentCount').textContent = comments.length; const user = detailUser || getAuthUser(); qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { - const owner = isCommentOwner(comment, user); - const editing = editingCommentId === comment.id && owner; + const owner = Boolean(comment.id) && isCommentOwner(comment, user); + const editing = Boolean(editingCommentId) && editingCommentId === comment.id && owner; const actions = owner ? '
' : ''; const content = editing ? '
' : '
' + escapeHtml(comment.content) + '
'; return '
' + escapeHtml(comment.author) + '' + escapeHtml(comment.date) + '
' + content + (comment.attachments.length ? '
' + renderCommentAttachments(comment.attachments) + '
' : '') + (editing ? '' : actions) + '
'; @@ -817,7 +817,7 @@ if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } return; } - if (cancelButton) { editingCommentId = ''; comments = renderComments(comments); return; } + if (cancelButton) { editingCommentId = null; comments = renderComments(comments); return; } if (saveButton) { const commentId = saveButton.getAttribute('data-comment-save'); const comment = comments.find(function (item) { return item.id === commentId; }); @@ -829,7 +829,7 @@ const result = await requestJson(commentsPath + '/' + encodeURIComponent(commentId), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: content }) }); const updatedComment = normalizeComment(Object.assign({}, comment, result.comment || result, { content: content })); comments = comments.map(function (item) { return item.id === commentId ? updatedComment : item; }); - editingCommentId = ''; + editingCommentId = null; renderComments(comments); showToast('댓글이 수정되었습니다.'); } catch (error) { showToast(error.message || '댓글 수정에 실패했습니다.'); saveButton.disabled = false; } @@ -871,7 +871,13 @@ } const result = await postToApi(commentsPath, payload); const localAttachments = files.map(function (file) { const url = URL.createObjectURL(file); return { name: file.name, url: url, thumbnailUrl: url }; }); - const savedComment = normalizeComment(result.comment || { id: result.id, content: content, author: (getAuthUser() || {}).name || '작성자', createdAt: new Date().toISOString(), attachments: localAttachments }); + const savedComment = normalizeComment(Object.assign({ + content: content, + author: (getAuthUser() || {}).name || '작성자', + createdAt: new Date().toISOString(), + attachments: localAttachments + }, result.comment || result.data || result)); + if (!savedComment.id) savedComment.id = normalizeComment(result).id; if (!savedComment.authorId) savedComment.authorId = (getAuthUser() || {}).requesterId || ''; if (!savedComment.attachments.length) savedComment.attachments = localAttachments; comments.push(savedComment); diff --git a/tests/page-comments.test.js b/tests/page-comments.test.js new file mode 100644 index 0000000..1179189 --- /dev/null +++ b/tests/page-comments.test.js @@ -0,0 +1,143 @@ +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 } = {}) { + 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]' + ].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: USER.requesterId }; + 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); +});