diff --git a/components/admin/AdminMemberActivityList.vue b/components/admin/AdminMemberActivityList.vue index 493ccff..edeba38 100644 --- a/components/admin/AdminMemberActivityList.vue +++ b/components/admin/AdminMemberActivityList.vue @@ -1,70 +1,85 @@ - - - {{ isComments ? '작성한 댓글' : '작성한 글' }} {{ data?.pagination.total || 0 }}개 · 최신순 - - + + + 작성한 글 {{ data?.pagination.total || 0 }}개 · 최신순 + + + 전체 + 댓글 + 게시글 + + 검색 목록을 불러오지 못했습니다. 다시 시도 목록을 불러오는 중입니다. - {{ filters.q ? '검색 결과가 없습니다.' : isComments ? '작성한 댓글이 없습니다.' : '작성한 글이 없습니다.' }} - - - - {{ formatCreatedAt(item.createdAt) }} - 답글 - {{ statusLabels[item.status] || item.status }} - - {{ item.body }} - - {{ (isComments ? item.postTitle : item.title) || '(제목 없음)' }} - 게시물 보기 - - - - + {{ filters.q ? '검색 결과가 없습니다.' : '작성한 글이 없습니다.' }} + + + 회원이 작성한 게시글과 댓글 목록 + + + 구분 + 게시글 + 작성자 + 작성일 + 내용 + + + + + {{ item.kind === 'post' ? '게시글' : '댓글' }} + + {{ item.postTitle || '(제목 없음)' }} + + {{ item.username }} + {{ formatCreatedAt(item.createdAt) }} + {{ item.body || '—' }} + + + + + diff --git a/components/admin/AdminMemberForm.vue b/components/admin/AdminMemberForm.vue index 20479f7..a058853 100644 --- a/components/admin/AdminMemberForm.vue +++ b/components/admin/AdminMemberForm.vue @@ -24,8 +24,7 @@ const { data: adminSession } = await useFetch('/admin/api/auth/me', { const selectedMemberTab = ref('info') const memberTabs = [ { value: 'info', label: '정보' }, - { value: 'posts', label: '작성한 글' }, - { value: 'comments', label: '작성한 댓글' } + { value: 'posts', label: '작성한 글' } ] const isNewMember = computed(() => props.mode === 'new') @@ -762,7 +761,7 @@ watch(() => props.member, () => { diff --git a/docs/history.md b/docs/history.md index be47a56..b3d0958 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-10-21 v1.12.24 — 멤버 작성 이력은 종류 필터가 있는 통합 표로 표시한다 + +멤버의 게시글과 댓글은 작성한 글 탭 하나에서 종류 열로 구분한다. 대시보드처럼 작성 대상·작성자·시각·내용을 한 줄로 표시해 여러 활동을 빠르게 훑도록 한다. 두 종류를 브라우저에서 따로 조회·합치면 전체 페이지와 최신순 정렬이 틀어질 수 있으므로 서버에서 작성자 조건을 적용한 게시글·댓글을 합친 뒤 종류 필터·검색·전체 건수·페이지를 계산한다. 기존 개별 API는 호환을 유지한다. + ## 2026-10-21 v1.12.23 — 대시보드는 최근 활동 요약, 멤버 이력은 탭으로 나눈다 대시보드는 새 댓글의 게시물·작성자·시각을 빠르게 파악하도록 최근 댓글을 작은 한 줄 목록으로 줄이고 CSP 관찰 표와 주기 조회를 제거한다. 이는 관리자 화면 정리이며, 제공된 누적 보고로 보안 정책 강화 여부를 확정한 결정은 아니다. 기존 수집·집계와 보안 헤더는 유지한다. diff --git a/docs/map.md b/docs/map.md index ff66741..ddca80e 100644 --- a/docs/map.md +++ b/docs/map.md @@ -312,7 +312,7 @@ | components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·슬래시 명령 실행·공통 포커스·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 | | components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) | -| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약·정보/작성한 글/작성한 댓글 탭·전체 이력 검색 및 번호형 페이지네이션, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) | +| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약·정보/작성한 글 탭·게시글/댓글 통합 표·종류 필터·검색 및 번호형 페이지네이션, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) | | components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) | | components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) | | components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) | @@ -641,6 +641,8 @@ | components/admin/AdminCommentList.vue | 대시보드 최신 댓글 5개 작은 목록·게시글/작성자/시각/한 줄 내용·30초 갱신·조회 오류 재시도 | | server/routes/admin/api/comments.get.js | 관리자 댓글 조회 인증·댓글 내용/게시물 제목 검색·Zod query 검증 | | server/repositories/comment-repository.js | 공개 댓글 조회·작성·좋아요와 관리자 댓글 최신순 페이지 조회 | -| tests/playwright/e2e/admin-comments.spec.js | 멤버 정보/작성 글/댓글 탭·검색·페이지 이동·원문 링크·작은 대시보드 댓글·CSP 제거·게시글 댓글 수 E2E 검증 | -| components/admin/AdminMemberActivityList.vue | 회원별 작성 글·댓글 탭의 전체 이력 최신순 검색·번호형 페이지네이션·게시물 이동 | +| tests/playwright/e2e/admin-comments.spec.js | 멤버 통합 작성 이력 표·전체/댓글/게시글 필터·검색·페이지 이동·원문 링크·작은 대시보드 댓글·CSP 제거·게시글 댓글 수 E2E 검증 | +| components/admin/AdminMemberActivityList.vue | 회원별 게시글·댓글 통합 한 줄 표·종류 필터·전체 이력 최신순 검색·번호형 페이지네이션·게시물 이동 | | server/routes/admin/api/members/[id]/posts.get.js | 관리자 회원 작성 글 조회 인증·Zod 검증·검색·페이지 API | +| server/repositories/member-activity-repository.js | 회원별 게시글·댓글 통합 최신순 검색·종류 필터·전체 건수 및 페이지 조회 | +| server/routes/admin/api/members/[id]/activity.get.js | 관리자 회원 통합 작성 이력 인증·Zod 조건 검증 API | diff --git a/docs/spec.md b/docs/spec.md index dd83625..64b0f22 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1089,15 +1089,21 @@ APP_PORT=43080 - 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가 - 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정 -## 관리자 댓글·회원 작성 이력 조회 (v1.12.23) +## 관리자 댓글·회원 작성 이력 조회 (v1.12.24) - `GET /admin/api/comments`: 관리자 세션 필수. `memberId`(선택 UUID), `q`(기본 빈 문자열, trim, 최대 200자, 댓글 내용·게시물 제목 부분 검색), `page`(기본 1, 1~1000000 정수), `pageSize`(기본 10, 1~100 정수). 잘못된 조건은 400, 미인증은 401. - 응답 `{ items, pagination: { page, pageSize, total, pageCount } }`. 항목은 `id`, `body`, `status`, `parentId`, `createdAt`, `userId`, `username`, `postId`, `postTitle`, `postSlug`, `postStatus` 제공. 모든 상태의 댓글·답글 포함, 작성 시각·ID 내림차순. 범위 초과 페이지는 마지막 페이지로 보정. -- 멤버 상세는 `정보 / 작성한 글 / 작성한 댓글` 탭을 제공하며 정보로 진입한다. 정보 편집값은 탭 이동 시 유지한다. 이력은 선택한 탭에서만 조회하고, 10개씩 번호형 페이지네이션·검색 버튼/Enter로 검색한다. 검색 시 첫 페이지로 돌아가며 최신 작성순으로 전체 이력을 조회한다. +- 멤버 상세는 `정보 / 작성한 글` 탭을 제공하며 정보로 진입한다. 정보 편집값은 탭 이동 시 유지한다. 작성한 글 탭에서 게시글·댓글 전체 이력을 `구분 / 게시글 / 작성자 / 작성일 / 내용` 한 줄 표로 통합 표시한다. 긴 제목·내용은 말줄임과 title로 표시하며 좁은 화면은 표 내부 가로 스크롤을 제공한다. +- 상단 `전체 / 댓글 / 게시글` 종류 필터와 검색 버튼/Enter 검색을 제공한다. 검색·종류 변경 시 첫 페이지로 돌아가며 10개씩 번호형 페이지네이션으로 최신순 전체 이력을 조회한다. - 대시보드는 최신 댓글 5개를 30초마다 갱신한다. 게시물·작성자·짧은 시각·말줄임 한 줄 내용만 작은 행으로 표시한다. 조회 오류는 재시도하며 이력 조회 중은 로딩 표시. -- 댓글 내용은 일반 텍스트로 표시. 작성자는 멤버 상세, 게시물 제목은 관리자 편집으로 이동. 공개·멤버십 글은 새 창의 `/post/:slug#comments` 링크 제공(공개 화면의 기존 접근 권한 적용). +- 댓글 내용은 일반 텍스트로 표시한다. 대시보드 작성자는 멤버 상세로 이동한다. 통합 표의 게시글 제목은 `publicUrl`이 있으면 새 창의 공개 화면으로, 없으면 관리자 편집으로 이동한다(공개 화면의 기존 접근 권한 적용). - 게시글 목록 댓글 열은 기존 `commentCount` 사용. 공개 상태 댓글·답글만 집계. - `GET /admin/api/members/:id/posts`: 관리자 세션 필수. 회원 UUID와 `q`(trim, 최대 200자), `page`(1~1000000, 기본 1), `pageSize`(1~100, 기본 10) 검증. 잘못된 조건 400, 미인증 401. - 작성 글은 `posts.author_id`로 필터하고 초안·비공개·멤버십·공개 전체 포함, `created_at DESC, id DESC` 순서. `q`는 제목·주소·요약·본문의 문자 그대로 부분 검색이며 댓글 검색도 `%`, `_`를 와일드카드로 취급하지 않는다. - 작성 글 응답 `{ items: [{ id, title, slug, status, createdAt, publishedAt }], pagination: { page, pageSize, total, pageCount } }`. 범위 초과 페이지는 마지막으로 보정하고 미작성·검색 결과 없음은 빈 목록 반환. + +- `GET /admin/api/members/:id/activity`: 관리자 회원 통합 작성 이력 조회. `memberId` UUID, `type`은 `all`(기본)/`post`/`comment`, `q`(trim, 최대 200자), `page`(기본 1, 1~1000000), `pageSize`(기본 10, 1~100). 미인증 401, 잘못된 조건 400. +- 게시글(`posts.author_id`)과 댓글·답글(`comments.user_id`)을 `UNION ALL`로 합쳐 `createdAt DESC, kind ASC, id DESC`로 정렬한 뒤 전체 건수·페이지를 계산한다. 검색은 게시글 제목·주소·요약·본문 또는 댓글 내용·대상 게시글 제목의 문자 그대로 부분 검색. 모든 상태를 포함하며 초과 페이지는 마지막 페이지로 보정한다. +- 응답 `{ items: [{ kind, id, postId, postTitle, postSlug, postStatus, publishedAt, createdAt, username, body, publicUrl }], pagination: { page, pageSize, total, pageCount } }`. `kind`는 `post`/`comment`. 게시글 body는 최대 320자 일반 텍스트 요약, 댓글은 원문 텍스트. 공개 접근 가능한 게시글은 `publicUrl` 제공(댓글은 `#comments`), 초안·비공개·예약 글은 null이며 관리자 편집으로 이동. +- 기존 `/admin/api/comments`, `/admin/api/members/:id/posts` 응답 계약은 유지하며 멤버 작성한 글 화면은 통합 `/activity`를 사용한다. diff --git a/docs/todo.md b/docs/todo.md index 902c7b7..75404ab 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -4,7 +4,7 @@ ## 2차 관리자 개발 -- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글·회원 작성 글 검색/페이지 API 검증은 통과. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인. +- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글·회원 통합 작성 이력 검색/페이지 API 검증은 통과. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인. ## 프론트엔드 개발 diff --git a/docs/update.md b/docs/update.md index 1c4bf35..55e4afe 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v1.12.24 + +- 멤버 작성 글·댓글 탭을 작성한 글 탭 하나로 통합. +- 구분·게시글·작성자·작성일·한 줄 내용 표와 전체/댓글/게시글 필터 추가. +- 게시글·댓글 통합 최신순 검색·페이지 조회 API와 검색·필터 변경 시 첫 페이지 조회 추가. +- 통합 정렬·작성자 격리·종류 필터·검색·페이지 이동 API/E2E 검증 보강. +- JavaScript 문법 검사·단위 테스트 273건·프로덕션 빌드·Chromium E2E 11건 검증 진행. +- API 통합 검증 11건 통과, 기존 미디어 사용처 검증 실패 1건·후속 미실행 1건 유지. + ## v1.12.23 - 대시보드 CSP 관찰 표·조회·자동 갱신 제거. diff --git a/package-lock.json b/package-lock.json index efb0d68..e17d6f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.12.23", + "version": "1.12.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.12.23", + "version": "1.12.24", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 500a73a..a34aa10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.12.23", + "version": "1.12.24", "private": true, "type": "module", "imports": { diff --git a/server/repositories/member-activity-repository.js b/server/repositories/member-activity-repository.js new file mode 100644 index 0000000..fe4ead3 --- /dev/null +++ b/server/repositories/member-activity-repository.js @@ -0,0 +1,55 @@ +import { getPostgresClient } from './postgres-client' +import { createPostSummary } from '../../composables/createPostSummary.js' + +/** + * 회원의 게시글·댓글을 합쳐 최신순으로 검색하고 페이지 조회한다. + * @param {{ memberId: string, type: 'all'|'post'|'comment', q: string, page: number, pageSize: number }} options - 조회 조건 + * @returns {Promise} 통합 작성 이력과 페이지 정보 + */ +export const listMemberActivityForAdmin = async ({ memberId, type, q, page, pageSize }) => { + const sql = getPostgresClient() + if (!sql) return { items: [], pagination: { page: 1, pageSize, total: 0, pageCount: 1 } } + + return sql.begin(async (transaction) => { + await transaction`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY` + /** + * 집계와 목록에 동일한 작성자·종류·검색 조건을 적용한다. + * @returns {Object} SQL 조회 조각 + */ + const activityQuery = () => transaction` + SELECT 'post'::text AS kind, posts.id, posts.id AS "postId", posts.title AS "postTitle", + posts.slug AS "postSlug", posts.status AS "postStatus", posts.published_at AS "publishedAt", + posts.created_at AS "createdAt", users.username, + LEFT(COALESCE(NULLIF(posts.excerpt, ''), posts.content, ''), 1000) AS body + FROM posts INNER JOIN users ON users.id = posts.author_id + WHERE posts.author_id = ${memberId} AND ${type} <> 'comment' + AND (${q} = '' OR strpos(lower(concat_ws(' ', posts.title, posts.slug, posts.excerpt, posts.content)), lower(${q})) > 0) + UNION ALL + SELECT 'comment'::text AS kind, comments.id, posts.id AS "postId", posts.title AS "postTitle", + posts.slug AS "postSlug", posts.status AS "postStatus", posts.published_at AS "publishedAt", + comments.created_at AS "createdAt", users.username, comments.body + FROM comments + INNER JOIN posts ON posts.id = comments.post_id + INNER JOIN users ON users.id = comments.user_id + WHERE comments.user_id = ${memberId} AND ${type} <> 'post' + AND (${q} = '' OR strpos(lower(concat_ws(' ', comments.body, posts.title)), lower(${q})) > 0) + ` + const [count] = await transaction`SELECT COUNT(*)::int AS total FROM (${activityQuery()}) AS activity` + const total = Number(count.total) + const pageCount = Math.max(1, Math.ceil(total / pageSize)) + const currentPage = Math.min(page, pageCount) + const rows = await transaction` + SELECT * FROM (${activityQuery()}) AS activity + ORDER BY "createdAt" DESC, kind ASC, id DESC + LIMIT ${pageSize} OFFSET ${(currentPage - 1) * pageSize} + ` + const items = rows.map((row) => ({ + ...row, + body: row.kind === 'post' ? createPostSummary('', row.body, { maxLength: 320 }) : row.body, + publicUrl: row.postStatus === 'members' || (row.postStatus === 'published' && (!row.publishedAt || row.publishedAt <= new Date())) + ? `/post/${encodeURIComponent(row.postSlug)}${row.kind === 'comment' ? '#comments' : ''}` + : null + })) + return { items, pagination: { page: currentPage, pageSize, total, pageCount } } + }) +} diff --git a/server/routes/admin/api/members/[id]/activity.get.js b/server/routes/admin/api/members/[id]/activity.get.js new file mode 100644 index 0000000..1bc4937 --- /dev/null +++ b/server/routes/admin/api/members/[id]/activity.get.js @@ -0,0 +1,26 @@ +import { createError, getQuery, getRouterParam } from 'h3' +import { z } from 'zod' +import { requireAdminSession } from '../../../../../utils/admin-auth' +import { listMemberActivityForAdmin } from '../../../../../repositories/member-activity-repository' + +const querySchema = z.object({ + memberId: z.string().uuid(), + type: z.enum(['all', 'post', 'comment']).default('all'), + q: z.string().trim().max(200).default(''), + page: z.coerce.number().int().min(1).max(1000000).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(10) +}) + +/** + * 관리자 회원 작성 이력을 게시글·댓글 통합 페이지로 조회한다. + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} 통합 작성 이력 + */ +export default defineEventHandler((event) => { + requireAdminSession(event) + const result = querySchema.safeParse({ ...getQuery(event), memberId: getRouterParam(event, 'id') }) + if (!result.success) { + throw createError({ statusCode: 400, message: '작성 이력 조회 조건이 올바르지 않습니다.' }) + } + return listMemberActivityForAdmin(result.data) +}) diff --git a/tests/playwright/api/core-api.spec.js b/tests/playwright/api/core-api.spec.js index 9d789d3..d0dc790 100644 --- a/tests/playwright/api/core-api.spec.js +++ b/tests/playwright/api/core-api.spec.js @@ -293,6 +293,38 @@ test('멤버 작성 글 조회는 작성자를 분리하고 전체 이력을 검 'draft', ${author.id}, now() + index * interval '1 second' FROM generate_series(1, 11) AS index ` + const [targetPost] = await database`SELECT id FROM posts WHERE author_id = ${author.id} ORDER BY created_at DESC LIMIT 1` + await database` + INSERT INTO comments (post_id, user_id, body, created_at) + VALUES (${targetPost.id}, ${author.id}, '통합 댓글 최신', now() + interval '20 second'), + (${targetPost.id}, ${author.id}, '통합 댓글 과거', now() - interval '1 day'), + (${targetPost.id}, ${adminUser.userId}, '다른 회원 댓글', now() + interval '30 second') + ` + const activityUrl = `/admin/api/members/${author.id}/activity` + expect((await anonymousApi.get(activityUrl)).status()).toBe(401) + for (const query of ['type=invalid', 'page=0', 'pageSize=101']) { + expect((await adminApi.get(`${activityUrl}?${query}`)).status()).toBe(400) + } + expect((await adminApi.get('/admin/api/members/invalid/activity')).status()).toBe(400) + const mixed = await (await adminApi.get(activityUrl)).json() + expect(mixed.pagination).toMatchObject({ total: 13, pageCount: 2, page: 1 }) + expect(mixed.items).toHaveLength(10) + expect(mixed.items[0]).toMatchObject({ kind: 'comment', body: '통합 댓글 최신', postId: targetPost.id, publicUrl: null }) + expect(mixed.items[1]).toMatchObject({ kind: 'post', postTitle: '작성 이력 11', body: '검색 본문 11' }) + const mixedLast = await (await adminApi.get(`${activityUrl}?page=999`)).json() + expect(mixedLast.pagination.page).toBe(2) + expect(mixedLast.items).toHaveLength(3) + expect(mixedLast.items[2].body).toBe('통합 댓글 과거') + const onlyComments = await (await adminApi.get(`${activityUrl}?type=comment`)).json() + expect(onlyComments.pagination.total).toBe(2) + expect(onlyComments.items.every((item) => item.kind === 'comment')).toBe(true) + const onlyPosts = await (await adminApi.get(`${activityUrl}?type=post`)).json() + expect(onlyPosts.pagination.total).toBe(11) + expect(onlyPosts.items.every((item) => item.kind === 'post')).toBe(true) + const activitySearch = await (await adminApi.get(`${activityUrl}?q=${encodeURIComponent('통합 댓글 최신')}`)).json() + expect(activitySearch.pagination.total).toBe(1) + const activityEmpty = await (await adminApi.get(`${activityUrl}?type=post&q=%25`)).json() + expect(activityEmpty.items).toEqual([]) const authorUrl = `/admin/api/members/${author.id}/posts` const first = await (await adminApi.get(authorUrl)).json() expect(first.pagination).toMatchObject({ total: 11, pageCount: 2, page: 1 }) diff --git a/tests/playwright/e2e/admin-comments.spec.js b/tests/playwright/e2e/admin-comments.spec.js index 1e5e597..69d5ab5 100644 --- a/tests/playwright/e2e/admin-comments.spec.js +++ b/tests/playwright/e2e/admin-comments.spec.js @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test' import postgres from 'postgres' -test('멤버 댓글 페이지 이동·게시물 링크와 대시보드 최근 댓글을 표시한다', async ({ page }) => { +test('멤버 통합 작성 이력의 종류 필터·검색·페이지와 대시보드 댓글을 표시한다', async ({ page }) => { const database = postgres(process.env.DATABASE_URL, { max: 1 }) let postId try { @@ -24,28 +24,36 @@ test('멤버 댓글 페이지 이동·게시물 링크와 대시보드 최근 await page.goto(`/admin/members/${admin.userId}`) await page.waitForLoadState('networkidle') await expect(page.locator('.admin-member-activity')).toHaveCount(0) - await page.getByRole('button', { name: '작성한 댓글', exact: true }).click() - const list = page.locator('.admin-member-activity') - await expect(list.locator('.admin-member-activity__item')).toHaveCount(10) - await expect(list.locator('.admin-member-activity__body').first()).toHaveText('화면 댓글 10') - await expect(list.getByRole('link', { name: '게시물 보기' }).first()).toHaveAttribute('href', '/post/comments-ui-test#comments') - await list.getByRole('button', { name: '다음 페이지' }).click() - await expect(list.locator('.admin-member-activity__item')).toHaveCount(1) - await expect(list.locator('.admin-member-activity__body')).toHaveText('화면 댓글 0') - await list.getByRole('button', { name: '이전 페이지' }).click() - await expect(list.locator('.admin-member-activity__item')).toHaveCount(10) - await list.getByLabel('작성한 댓글 검색').fill('화면 댓글 0') - await list.getByRole('button', { name: '검색', exact: true }).click() - await expect(list.locator('.admin-member-activity__item')).toHaveCount(1) - await expect(list.locator('.admin-member-activity__body')).toHaveText('화면 댓글 0') - await list.getByLabel('작성한 댓글 검색').fill('결과없음') - await list.getByRole('button', { name: '검색', exact: true }).click() - await expect(list).toContainText('검색 결과가 없습니다.') + await expect(page.getByRole('button', { name: '작성한 댓글', exact: true })).toHaveCount(0) await page.getByRole('button', { name: '작성한 글', exact: true }).click() - await expect(page.locator('.admin-member-activity__item')).toHaveCount(1) - await page.getByLabel('작성한 글 검색').fill('없는글') - await page.locator('.admin-member-activity').getByRole('button', { name: '검색', exact: true }).click() - await expect(page.locator('.admin-member-activity')).toContainText('검색 결과가 없습니다.') + const list = page.locator('.admin-member-activity') + const items = list.locator('.admin-member-activity__item') + await expect(items).toHaveCount(10) + await expect(list.locator('.admin-member-activity__body').first()).toHaveText('화면 댓글 10') + await expect(list.locator('.admin-member-activity__post').first()).toHaveAttribute('href', '/post/comments-ui-test#comments') + await list.getByRole('button', { name: '다음 페이지' }).click() + await expect(items).toHaveCount(2) + await expect(list.locator('.admin-member-activity__kind')).toHaveText(['댓글', '게시글']) + await list.getByLabel('작성 종류').selectOption('comment') + await expect(items).toHaveCount(10) + await expect(list.locator('.admin-member-activity__kind').first()).toHaveText('댓글') + await list.getByRole('button', { name: '다음 페이지' }).click() + await expect(items).toHaveCount(1) + await list.getByLabel('작성한 글 검색').fill('화면 댓글 0') + await list.getByRole('button', { name: '검색', exact: true }).click() + await expect(items).toHaveCount(1) + await expect(list.locator('.admin-member-activity__body')).toHaveText('화면 댓글 0') + await list.getByLabel('작성 종류').selectOption('post') + await expect(list).toContainText('검색 결과가 없습니다.') + await list.getByLabel('작성한 글 검색').fill('') + await list.getByRole('button', { name: '검색', exact: true }).click() + await expect(items).toHaveCount(1) + await expect(list.locator('.admin-member-activity__kind')).toHaveText('게시글') + await expect(list.locator('.admin-member-activity__body')).toHaveText('본문') + await list.getByLabel('작성 종류').selectOption('all') + await expect(items).toHaveCount(10) + const rowBox = await items.first().boundingBox() + expect(rowBox.height).toBeLessThan(40) await page.getByRole('button', { name: '정보', exact: true }).click() await expect(page.locator('.admin-member-form__body')).toBeVisible() await page.goto('/admin')
목록을 불러오지 못했습니다. 다시 시도
목록을 불러오는 중입니다.
{{ filters.q ? '검색 결과가 없습니다.' : isComments ? '작성한 댓글이 없습니다.' : '작성한 글이 없습니다.' }}
{{ item.body }}
{{ filters.q ? '검색 결과가 없습니다.' : '작성한 글이 없습니다.' }}
{{ item.body || '—' }}