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 ? '' : '';
return '';
@@ -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);
+});