diff --git a/app/[username]/[type]/[slug]/page.tsx b/app/[username]/[type]/[slug]/page.tsx index aaa1b78..0ff1da7 100644 --- a/app/[username]/[type]/[slug]/page.tsx +++ b/app/[username]/[type]/[slug]/page.tsx @@ -5,35 +5,25 @@ import Link from 'next/link' import Image from 'next/image' import { getSession, resolveIsAdmin } from '@/lib/auth' import { getCachedPost } from '@/lib/posts/lookup' -import { getEngagementState } from '@/lib/posts/engagement' import { postUrl } from '@/lib/posts/url' import { hasMermaid } from '@/lib/posts/has-mermaid' import { articleJsonLd } from '@/lib/json-ld' -import { createAdminSupabaseClient } from '@/lib/supabase/admin' import { PostBodyStatic } from '@/components/posts/PostBodyStatic' import { MermaidHydratorClient } from '@/components/posts/MermaidHydratorClient' import { StructuredSections } from '@/components/posts/StructuredSections' import { ErrorBoundary } from '@/components/error/ErrorBoundary' import { MdxFailedFallback } from '@/components/error/MdxFailedFallback' -import { ViewBeacon } from '@/components/posts/ViewBeacon' import { AuthorActions } from '@/components/posts/AuthorActions' import { Backlinks } from '@/components/posts/Backlinks' -import { CommentsSection } from '@/components/post/CommentsSection' -import { LikeButton } from '@/components/post/LikeButton' -import { BookmarkButton } from '@/components/post/BookmarkButton' -import { FollowButton } from '@/components/profile/FollowButton' -import { getFollowState } from '@/lib/profile/follow-state' import { ReportButton } from '@/components/report/ReportButton' -import { CommentSkeleton } from '@/components/skeleton/CommentSkeleton' import { logRouteError } from '@/lib/logging/error-log' // Home discovery rails — the read page reuses the exact same three-column -// shell as `/` (issue #70). Left nav only on the left; trending tags + -// TopByType + featured-tags fallback consolidated on the right. Same +// shell as `/` (issue #70). Left nav only on the left; TopByType + +// featured-tags fallback consolidated on the right. Same // `unstable_cache`-backed data (#54); no new caching layers. import { HomeShell } from '@/components/home/HomeShell' import { LeftSidebar } from '@/components/home/LeftSidebar' import { RightSidebar } from '@/components/home/RightSidebar' -import { TrendingStrip } from '@/components/home/TrendingStrip' import { TopByType } from '@/components/home/TopByType' import { RailSkeleton } from '@/components/skeleton/RailSkeleton' @@ -128,16 +118,6 @@ export default async function PostPage({ isAdminUser = await resolveIsAdmin(session.user.id) } - const admin = createAdminSupabaseClient() - const [engagement, viewerFollowsAuthor] = await Promise.all([ - getEngagementState({ admin, postId: post.id, userId: session?.user?.id }), - getFollowState({ - admin, - targetUserId: post.author_id, - viewerUserId: session?.user?.id ?? null, - }), - ]) - const leadingSegment = post.org ? post.org.slug : post.author.username const canonicalPath = postUrl(leadingSegment, post.type, post.slug) @@ -191,15 +171,6 @@ export default async function PostPage({ left={} center={
- {/* Mobile-only (=lg; the - wrapper supplies the same gutter as the post body so the strip - lines up with the article. */} -
- - - -
' }) as never, - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('empty_body') - }) -}) - -describe('POST /api/comments — sanitize strips HTML tags before insert', () => { - beforeEach(() => { - sessionState.value = { user: { id: USER_ID } } - capturedInserts.length = 0 - depthState.value = 1 - depthState.throwError = null - currentFakeClient = makeHappyClient({ - insertedRow: { - id: NEW_COMMENT_ID, - post_id: POST_ID, - parent_comment_id: null, - body: 'hello world', - author_id: USER_ID, - created_at: '2026-05-30T00:00:00.000Z', - }, - }) - }) - - it('strips hello world to hello world in the insert payload', async () => { - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ post_id: POST_ID, body: 'hello world' }) as never, - ) - expect(res.status).toBe(201) - const insert = capturedInserts.find((r) => r.table === 'comments') - expect(insert).toBeDefined() - const row = insert!.rows as { body: string } - expect(row.body).toBe('hello world') - }) -}) - -describe('POST /api/comments — 404 post_not_found', () => { - beforeEach(() => { - sessionState.value = { user: { id: USER_ID } } - capturedInserts.length = 0 - depthState.value = 1 - depthState.throwError = null - }) - - it('returns 404 when post is missing', async () => { - currentFakeClient = makeHappyClient({ postRow: null }) - const { POST } = await import('@/app/api/comments/route') - const res = await POST(makeRequest({ post_id: POST_ID, body: 'hi' }) as never) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBe('post_not_found') - }) - - it('returns 404 when post is soft-deleted', async () => { - currentFakeClient = makeHappyClient({ - postRow: { id: POST_ID, deleted_at: '2026-01-01T00:00:00Z' }, - }) - const { POST } = await import('@/app/api/comments/route') - const res = await POST(makeRequest({ post_id: POST_ID, body: 'hi' }) as never) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBe('post_not_found') - }) -}) - -describe('POST /api/comments — 400 parent_not_found', () => { - beforeEach(() => { - sessionState.value = { user: { id: USER_ID } } - capturedInserts.length = 0 - depthState.value = 2 - depthState.throwError = null - }) - - it('returns 400 parent_not_found when parent is missing', async () => { - currentFakeClient = makeHappyClient({ parentRow: null }) - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ post_id: POST_ID, parent_comment_id: PARENT_ID, body: 'hi' }) as never, - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('parent_not_found') - }) - - it('returns 400 parent_not_found when parent belongs to a different post', async () => { - currentFakeClient = makeHappyClient({ - parentRow: { - id: PARENT_ID, - post_id: '99999999-9999-4999-8999-999999999999', - deleted_at: null, - }, - }) - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ post_id: POST_ID, parent_comment_id: PARENT_ID, body: 'hi' }) as never, - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('parent_not_found') - }) - - it('returns 400 parent_not_found when parent is soft-deleted', async () => { - currentFakeClient = makeHappyClient({ - parentRow: { - id: PARENT_ID, - post_id: POST_ID, - deleted_at: '2026-01-01T00:00:00Z', - }, - }) - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ post_id: POST_ID, parent_comment_id: PARENT_ID, body: 'hi' }) as never, - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('parent_not_found') - }) -}) - -describe('POST /api/comments — 400 depth_exceeded', () => { - beforeEach(() => { - sessionState.value = { user: { id: USER_ID } } - capturedInserts.length = 0 - depthState.value = 6 - depthState.throwError = null - currentFakeClient = makeHappyClient({ - parentRow: { id: PARENT_ID, post_id: POST_ID, deleted_at: null }, - }) - }) - - it('returns 400 depth_exceeded when depth > 5', async () => { - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ post_id: POST_ID, parent_comment_id: PARENT_ID, body: 'hi' }) as never, - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('depth_exceeded') - expect(body.max).toBe(5) - }) -}) - -describe('POST /api/comments — 201 happy path (root)', () => { - beforeEach(() => { - sessionState.value = { user: { id: USER_ID } } - capturedInserts.length = 0 - depthState.value = 1 - depthState.throwError = null - currentFakeClient = makeHappyClient({ - insertedRow: { - id: NEW_COMMENT_ID, - post_id: POST_ID, - parent_comment_id: null, - body: 'hello world', - author_id: USER_ID, - created_at: '2026-05-30T00:00:00.000Z', - }, - }) - }) - - it('returns 201 with inserted row for root comment (no parent)', async () => { - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ post_id: POST_ID, body: 'hello world' }) as never, - ) - expect(res.status).toBe(201) - const body = await res.json() - expect(body).toEqual({ - id: NEW_COMMENT_ID, - post_id: POST_ID, - parent_comment_id: null, - body: 'hello world', - author_id: USER_ID, - created_at: '2026-05-30T00:00:00.000Z', - }) - - const insert = capturedInserts.find((r) => r.table === 'comments') - expect(insert).toBeDefined() - const row = insert!.rows as Record - expect(row.parent_comment_id).toBeNull() - expect(row.author_id).toBe(USER_ID) - expect(row.post_id).toBe(POST_ID) - }) -}) - -describe('POST /api/comments — 201 happy path (reply at depth 4)', () => { - beforeEach(() => { - sessionState.value = { user: { id: USER_ID } } - capturedInserts.length = 0 - depthState.value = 4 - depthState.throwError = null - currentFakeClient = makeHappyClient({ - parentRow: { id: PARENT_ID, post_id: POST_ID, deleted_at: null }, - insertedRow: { - id: NEW_COMMENT_ID, - post_id: POST_ID, - parent_comment_id: PARENT_ID, - body: 'a reply', - author_id: USER_ID, - created_at: '2026-05-30T00:00:00.000Z', - }, - }) - }) - - it('returns 201 with inserted row for reply at depth 4 (still under 5)', async () => { - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ - post_id: POST_ID, - parent_comment_id: PARENT_ID, - body: 'a reply', - }) as never, - ) - expect(res.status).toBe(201) - const body = await res.json() - expect(body.parent_comment_id).toBe(PARENT_ID) - expect(body.id).toBe(NEW_COMMENT_ID) - }) -}) - -// --------------------------------------------------------------------------- -// Phase 14 — abuse heuristics on POST /api/comments -// --------------------------------------------------------------------------- - -describe('POST /api/comments — Phase 14 abuse heuristics', () => { - beforeEach(() => { - sessionState.value = { user: { id: USER_ID } } - capturedInserts.length = 0 - depthState.value = 1 - depthState.throwError = null - currentFakeClient = makeHappyClient() - }) - - it('returns 400 spam_detected when honeypot field is filled', async () => { - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ - post_id: POST_ID, - body: 'hello', - _h: 'i-am-a-bot', - }) as never, - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('spam_detected') - // Generic — must NOT leak the reason. - expect(JSON.stringify(body)).not.toContain('honeypot') - // Must short-circuit before the DB. - expect(capturedInserts.length).toBe(0) - }) - - it('passes through when honeypot field is present but empty', async () => { - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ - post_id: POST_ID, - body: 'hello', - _h: '', - }) as never, - ) - expect(res.status).toBe(201) - }) - - it('returns 400 too_many_urls when body is URL-heavy', async () => { - const { POST } = await import('@/app/api/comments/route') - const res = await POST( - makeRequest({ - post_id: POST_ID, - body: 'https://a.com https://b.com https://c.com hi', - }) as never, - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('too_many_urls') - expect(capturedInserts.length).toBe(0) - }) - - it('returns 403 forbidden_origin when Origin header is absent', async () => { - const req = new Request('http://test/api/comments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ post_id: POST_ID, body: 'hi' }), - }) - const { POST } = await import('@/app/api/comments/route') - const res = await POST(req as never) - expect(res.status).toBe(403) - expect(await res.json()).toEqual({ error: 'forbidden_origin' }) - }) -}) diff --git a/tests/unit/api/comments-delete.test.ts b/tests/unit/api/comments-delete.test.ts deleted file mode 100644 index d2d164f..0000000 --- a/tests/unit/api/comments-delete.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' - -// --------------------------------------------------------------------------- -// Mock: @/lib/auth -// --------------------------------------------------------------------------- -const sessionState: { value: { user: { id: string } } | null } = { value: null } -const isAdminState = { value: false } - -vi.mock('@/lib/auth', () => ({ - getSession: vi.fn(async () => sessionState.value), - isAdmin: vi.fn(() => isAdminState.value), - resolveIsAdmin: vi.fn(async () => isAdminState.value), -})) - -// --------------------------------------------------------------------------- -// Mock: @/lib/supabase/admin -// --------------------------------------------------------------------------- -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let currentFakeClient: any = {} - -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(() => currentFakeClient), -})) - -// --------------------------------------------------------------------------- -// Captured operations -// --------------------------------------------------------------------------- -interface CapturedOp { table: string; op: string; payload: unknown } -const capturedOps: CapturedOp[] = [] - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- -const COMMENT_ID = '33333333-3333-4333-8333-333333333333' -const AUTHOR_ID = 'author-123' - -interface CommentRow { - id: string - author_id: string - body: string - created_at: string - deleted_at: string | null -} - -const LIVE_COMMENT: CommentRow = { - id: COMMENT_ID, - author_id: AUTHOR_ID, - body: 'live body', - created_at: '2026-05-30T00:00:00.000Z', - deleted_at: null, -} - -const DELETED_COMMENT: CommentRow = { - ...LIVE_COMMENT, - deleted_at: '2026-01-01T00:00:00Z', -} - -// --------------------------------------------------------------------------- -// Builders -// --------------------------------------------------------------------------- - -function commentsHandler(commentRow: CommentRow | null) { - return { - select: vi.fn(() => ({ - eq: vi.fn(() => ({ - single: vi.fn(() => - Promise.resolve( - commentRow - ? { data: commentRow, error: null } - : { data: null, error: { message: 'not found' } }, - ), - ), - })), - })), - update: vi.fn((payload: unknown) => ({ - eq: vi.fn((_field: string, _val: unknown) => { - capturedOps.push({ table: 'comments', op: 'update', payload }) - return Promise.resolve({ data: null, error: null }) - }), - })), - } -} - -function modActionsHandler(opts: { insertError?: { message: string } | null } = {}) { - const { insertError = null } = opts - return { - insert: vi.fn((payload: unknown) => { - capturedOps.push({ table: 'mod_actions', op: 'insert', payload }) - return Promise.resolve({ data: null, error: insertError }) - }), - } -} - -function makeDeleteClient(opts: { - commentRow?: CommentRow | null - modActionsInsertError?: { message: string } | null -} = {}) { - const { commentRow = LIVE_COMMENT, modActionsInsertError = null } = opts - return { - from: vi.fn((table: string) => { - if (table === 'comments') return commentsHandler(commentRow) - if (table === 'mod_actions') return modActionsHandler({ insertError: modActionsInsertError }) - return {} - }), - } -} - -function makeRequest(commentId: string, body?: string) { - return new Request(`http://test/api/comments/${commentId}`, { - method: 'DELETE', - headers: { Origin: 'http://localhost:3010' }, - body: body ?? undefined, - }) -} - -function makeContext(id: string) { - return { params: Promise.resolve({ id }) } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('DELETE /api/comments/[id] — 401 unauthenticated', () => { - beforeEach(() => { - sessionState.value = null - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makeDeleteClient() - }) - - it('returns 401 when no session', async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(401) - const body = await res.json() - expect(body).toEqual({ error: 'unauthorized' }) - }) -}) - -describe('DELETE /api/comments/[id] — 404 comment missing / already deleted', () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = false - capturedOps.length = 0 - }) - - it('returns 404 when comment does not exist', async () => { - currentFakeClient = makeDeleteClient({ commentRow: null }) - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBe('not_found') - }) - - it('returns 404 when comment is already soft-deleted', async () => { - currentFakeClient = makeDeleteClient({ commentRow: DELETED_COMMENT }) - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBe('not_found') - }) -}) - -describe('DELETE /api/comments/[id] — 403 neither author nor admin', () => { - beforeEach(() => { - sessionState.value = { user: { id: 'random-user' } } - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makeDeleteClient() - }) - - it('returns 403 when requestor is not author and not admin', async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(403) - const body = await res.json() - expect(body.error).toBe('forbidden') - }) -}) - -describe("DELETE /api/comments/[id] — author delete sets deletion_reason='author'", () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makeDeleteClient() - }) - - it("returns 200 with deletion_reason='author' and does NOT null the body", async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ ok: true, deletion_reason: 'author' }) - - const updateOp = capturedOps.find( - (op) => op.table === 'comments' && op.op === 'update', - ) - expect(updateOp).toBeDefined() - const payload = updateOp!.payload as Record - expect(payload.deletion_reason).toBe('author') - expect(typeof payload.deleted_at).toBe('string') - // body must be retained for audit (placeholder is render-layer concern) - expect(payload).not.toHaveProperty('body') - }) - - it('does NOT insert a mod_actions row on author delete', async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - - const modOp = capturedOps.find((op) => op.table === 'mod_actions') - expect(modOp).toBeUndefined() - }) -}) - -describe("DELETE /api/comments/[id] — admin (non-author) delete sets deletion_reason='moderation'", () => { - beforeEach(() => { - sessionState.value = { user: { id: 'admin-user' } } - isAdminState.value = true - capturedOps.length = 0 - currentFakeClient = makeDeleteClient() - }) - - it("returns 200 with deletion_reason='moderation' when admin (non-author) deletes", async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ ok: true, deletion_reason: 'moderation' }) - - const updateOp = capturedOps.find( - (op) => op.table === 'comments' && op.op === 'update', - ) - expect(updateOp).toBeDefined() - const payload = updateOp!.payload as Record - expect(payload.deletion_reason).toBe('moderation') - }) - - it('inserts a mod_actions row with correct fields on admin delete', async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE( - makeRequest(COMMENT_ID, JSON.stringify({ reason: 'harassment' })) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(200) - - const modOp = capturedOps.find((op) => op.table === 'mod_actions' && op.op === 'insert') - expect(modOp).toBeDefined() - const modPayload = modOp!.payload as Record - expect(modPayload.mod_user_id).toBe('admin-user') - expect(modPayload.action).toBe('delete_comment') - expect(modPayload.target_type).toBe('comment') - expect(modPayload.target_id).toBe(COMMENT_ID) - expect(modPayload.reason).toBe('harassment') - expect(modPayload.metadata).toEqual({ author_id: AUTHOR_ID }) - }) - - it('inserts mod_actions row with reason=null when no body provided', async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(200) - - const modOp = capturedOps.find((op) => op.table === 'mod_actions' && op.op === 'insert') - expect(modOp).toBeDefined() - const modPayload = modOp!.payload as Record - expect(modPayload.reason).toBeNull() - }) - - it('returns 200 even when mod_actions insert fails (soft failure)', async () => { - currentFakeClient = makeDeleteClient({ - modActionsInsertError: { message: 'insert error' }, - }) - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - // Deletion succeeded; audit insert failed but we still return 200 - expect(res.status).toBe(200) - const body = await res.json() - expect(body.ok).toBe(true) - }) -}) - -describe("DELETE /api/comments/[id] — author who is also admin gets 'author' (precedence)", () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = true - capturedOps.length = 0 - currentFakeClient = makeDeleteClient() - }) - - it("sets deletion_reason='author' when author is also admin", async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - const res = await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(200) - const body = await res.json() - expect(body.deletion_reason).toBe('author') - - const updateOp = capturedOps.find( - (op) => op.table === 'comments' && op.op === 'update', - ) - expect(updateOp).toBeDefined() - const payload = updateOp!.payload as Record - expect(payload.deletion_reason).toBe('author') - }) - - it('does NOT insert mod_actions when author-admin deletes their own comment', async () => { - const { DELETE } = await import('@/app/api/comments/[id]/route') - await DELETE(makeRequest(COMMENT_ID) as never, makeContext(COMMENT_ID)) - - const modOp = capturedOps.find((op) => op.table === 'mod_actions') - expect(modOp).toBeUndefined() - }) -}) diff --git a/tests/unit/api/comments-patch.test.ts b/tests/unit/api/comments-patch.test.ts deleted file mode 100644 index 6b33343..0000000 --- a/tests/unit/api/comments-patch.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' - -// --------------------------------------------------------------------------- -// Mock: @/lib/auth -// --------------------------------------------------------------------------- -const sessionState: { value: { user: { id: string } } | null } = { value: null } -const isAdminState = { value: false } - -vi.mock('@/lib/auth', () => ({ - getSession: vi.fn(async () => sessionState.value), - isAdmin: vi.fn(() => isAdminState.value), - resolveIsAdmin: vi.fn(async () => isAdminState.value), -})) - -// --------------------------------------------------------------------------- -// Mock: @/lib/supabase/admin -// --------------------------------------------------------------------------- -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let currentFakeClient: any = {} - -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(() => currentFakeClient), -})) - -// --------------------------------------------------------------------------- -// Captured operations -// --------------------------------------------------------------------------- -interface CapturedOp { table: string; op: string; payload: unknown } -const capturedOps: CapturedOp[] = [] - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- -const COMMENT_ID = '33333333-3333-4333-8333-333333333333' -const AUTHOR_ID = 'author-123' -const RECENT_CREATED_AT = new Date(Date.now() - 60 * 60 * 1000).toISOString() // 1 hour ago -const OLD_CREATED_AT = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString() // 48 hours ago - -interface CommentRow { - id: string - author_id: string - body: string - created_at: string - deleted_at: string | null -} - -// --------------------------------------------------------------------------- -// Builders -// --------------------------------------------------------------------------- - -function commentsHandler(commentRow: CommentRow | null) { - return { - select: vi.fn(() => ({ - eq: vi.fn(() => ({ - single: vi.fn(() => - Promise.resolve( - commentRow - ? { data: commentRow, error: null } - : { data: null, error: { message: 'not found' } }, - ), - ), - })), - })), - update: vi.fn((payload: unknown) => ({ - eq: vi.fn((_field: string, _val: unknown) => { - capturedOps.push({ table: 'comments', op: 'update', payload }) - return Promise.resolve({ data: null, error: null }) - }), - })), - } -} - -function makePatchClient(opts: { commentRow?: CommentRow | null } = {}) { - const { - commentRow = { - id: COMMENT_ID, - author_id: AUTHOR_ID, - body: 'old body', - created_at: RECENT_CREATED_AT, - deleted_at: null, - }, - } = opts - - return { - from: vi.fn((table: string) => { - if (table === 'comments') return commentsHandler(commentRow) - return {} - }), - } -} - -// --------------------------------------------------------------------------- -// Request helpers -// --------------------------------------------------------------------------- -function makeRequest(commentId: string, body: unknown) { - return new Request(`http://test/api/comments/${commentId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Origin: 'http://localhost:3010', - }, - body: typeof body === 'string' ? body : JSON.stringify(body), - }) -} - -function makeContext(id: string) { - return { params: Promise.resolve({ id }) } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('PATCH /api/comments/[id] — 401 unauthenticated', () => { - beforeEach(() => { - sessionState.value = null - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makePatchClient() - }) - - it('returns 401 when no session', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH(makeRequest(COMMENT_ID, { body: 'x' }) as never, makeContext(COMMENT_ID)) - expect(res.status).toBe(401) - const body = await res.json() - expect(body).toEqual({ error: 'unauthorized' }) - }) -}) - -describe('PATCH /api/comments/[id] — 404 comment missing', () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makePatchClient({ commentRow: null }) - }) - - it('returns 404 when comment does not exist', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH( - makeRequest(COMMENT_ID, { body: 'updated' }) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBe('not_found') - }) -}) - -describe('PATCH /api/comments/[id] — 404 already deleted', () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makePatchClient({ - commentRow: { - id: COMMENT_ID, - author_id: AUTHOR_ID, - body: 'old', - created_at: RECENT_CREATED_AT, - deleted_at: '2026-01-01T00:00:00Z', - }, - }) - }) - - it('returns 404 when comment is already soft-deleted', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH( - makeRequest(COMMENT_ID, { body: 'updated' }) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBe('not_found') - }) -}) - -describe('PATCH /api/comments/[id] — 403 not author (admin does not get edit)', () => { - beforeEach(() => { - sessionState.value = { user: { id: 'other-user' } } - isAdminState.value = true // admin should NOT bypass author-only edit gate - capturedOps.length = 0 - currentFakeClient = makePatchClient() - }) - - it('returns 403 when requestor is not the author, even if admin', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH( - makeRequest(COMMENT_ID, { body: 'updated' }) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(403) - const body = await res.json() - expect(body.error).toBe('forbidden') - }) -}) - -describe('PATCH /api/comments/[id] — 403 edit_window_expired', () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makePatchClient({ - commentRow: { - id: COMMENT_ID, - author_id: AUTHOR_ID, - body: 'old', - created_at: OLD_CREATED_AT, - deleted_at: null, - }, - }) - }) - - it('returns 403 edit_window_expired when comment is older than 24h', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH( - makeRequest(COMMENT_ID, { body: 'updated' }) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(403) - const body = await res.json() - expect(body.error).toBe('edit_window_expired') - }) -}) - -describe('PATCH /api/comments/[id] — 400 invalid_body', () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makePatchClient() - }) - - it('returns 400 invalid_body when body field is missing', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH( - makeRequest(COMMENT_ID, {}) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('invalid_body') - }) -}) - -describe('PATCH /api/comments/[id] — 400 empty_body after sanitize', () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makePatchClient() - }) - - it('returns 400 empty_body when sanitized body is empty', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH( - makeRequest(COMMENT_ID, { body: '' }) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('empty_body') - }) -}) - -describe('PATCH /api/comments/[id] — happy path (author within 24h)', () => { - beforeEach(() => { - sessionState.value = { user: { id: AUTHOR_ID } } - isAdminState.value = false - capturedOps.length = 0 - currentFakeClient = makePatchClient() - }) - - it('returns 200 with { id, body, edited_at } on valid update', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH( - makeRequest(COMMENT_ID, { body: 'updated body' }) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(200) - const body = await res.json() - expect(body.id).toBe(COMMENT_ID) - expect(body.body).toBe('updated body') - expect(typeof body.edited_at).toBe('string') - expect(new Date(body.edited_at).getTime()).toBeGreaterThan(0) - }) - - it('update payload contains only body + edited_at (not post_id/parent_comment_id)', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - await PATCH( - makeRequest(COMMENT_ID, { body: 'updated body' }) as never, - makeContext(COMMENT_ID), - ) - - const updateOp = capturedOps.find( - (op) => op.table === 'comments' && op.op === 'update', - ) - expect(updateOp).toBeDefined() - const payload = updateOp!.payload as Record - expect(payload.body).toBe('updated body') - expect(typeof payload.edited_at).toBe('string') - // Immutable fields must not be touched - expect(payload).not.toHaveProperty('post_id') - expect(payload).not.toHaveProperty('parent_comment_id') - }) - - it('strips HTML tags before update', async () => { - const { PATCH } = await import('@/app/api/comments/[id]/route') - const res = await PATCH( - makeRequest(COMMENT_ID, { body: 'clean text' }) as never, - makeContext(COMMENT_ID), - ) - expect(res.status).toBe(200) - const body = await res.json() - expect(body.body).toBe('clean text') - - const updateOp = capturedOps.find( - (op) => op.table === 'comments' && op.op === 'update', - ) - expect(updateOp).toBeDefined() - const payload = updateOp!.payload as Record - expect(payload.body).toBe('clean text') - }) -}) diff --git a/tests/unit/api/comments/restore.test.ts b/tests/unit/api/comments/restore.test.ts deleted file mode 100644 index 9820389..0000000 --- a/tests/unit/api/comments/restore.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' - -// Mocks mirror tests/unit/api/posts/restore.test.ts. - -const sessionState: { value: { user: { id: string } } | null } = { value: null } - -vi.mock('@/lib/auth', () => ({ - getSession: vi.fn(async () => sessionState.value), -})) - -let adminGateResult: Response | null = null - -vi.mock('@/lib/admin', () => ({ - requireAdminApi: vi.fn(async () => adminGateResult), -})) - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let currentFakeClient: any = {} - -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(() => currentFakeClient), -})) - -const ADMIN_ID = 'aabbccdd-1234-4000-8001-000000000001' -const AUTHOR_ID = 'aabbccdd-1234-4000-8001-000000000002' -const COMMENT_ID = 'aabbccdd-1234-4000-8001-000000000050' - -const MOD_DELETED_COMMENT = { - id: COMMENT_ID, - author_id: AUTHOR_ID, - deleted_at: '2026-05-01T00:00:00.000Z', - deletion_reason: 'moderation' as const, -} -const AUTHOR_DELETED_COMMENT = { ...MOD_DELETED_COMMENT, deletion_reason: 'author' as const } -const LIVE_COMMENT = { ...MOD_DELETED_COMMENT, deleted_at: null, deletion_reason: null } - -function makeFakeClient(opts: { - commentRow?: unknown - updateError?: { message: string } | null - modActionsError?: { message: string } | null -} = {}) { - const { commentRow = MOD_DELETED_COMMENT, updateError = null, modActionsError = null } = opts - - const modActionsInsertFn = vi.fn(async () => ({ error: modActionsError })) - const updateEqFn = vi.fn(async () => ({ error: updateError })) - const updateFn = vi.fn(() => ({ eq: updateEqFn })) - - return { - from: vi.fn((table: string) => { - if (table === 'comments') { - return { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn(async () => ({ - data: commentRow, - error: commentRow ? null : { message: 'not found' }, - })), - update: updateFn, - } - } - if (table === 'mod_actions') { - return { insert: modActionsInsertFn } - } - return { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn(async () => ({ data: null, error: null })), - } - }), - modActionsInsertFn, - updateFn, - } -} - -function makeRequest(body: unknown = {}) { - return new Request('http://test/api/comments/x/restore', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Origin: 'http://localhost:3010', - }, - body: JSON.stringify(body), - }) -} - -function params() { - return { params: Promise.resolve({ id: COMMENT_ID }) } -} - -describe('POST /api/comments/[id]/restore', () => { - it('returns 401 when no session', async () => { - sessionState.value = null - adminGateResult = new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }) - currentFakeClient = makeFakeClient() - const { POST } = await import('@/app/api/comments/[id]/restore/route') - expect((await POST(makeRequest(), params())).status).toBe(401) - }) - - it('returns 404 when authed non-admin', async () => { - sessionState.value = { user: { id: AUTHOR_ID } } - adminGateResult = new Response(JSON.stringify({ error: 'not_found' }), { status: 404 }) - currentFakeClient = makeFakeClient() - const { POST } = await import('@/app/api/comments/[id]/restore/route') - expect((await POST(makeRequest(), params())).status).toBe(404) - }) - - describe('admin', () => { - beforeEach(() => { - sessionState.value = { user: { id: ADMIN_ID } } - adminGateResult = null - }) - - it('returns 404 when comment does not exist', async () => { - currentFakeClient = makeFakeClient({ commentRow: null }) - const { POST } = await import('@/app/api/comments/[id]/restore/route') - expect((await POST(makeRequest(), params())).status).toBe(404) - }) - - it('returns 400 not_deleted on a live comment', async () => { - currentFakeClient = makeFakeClient({ commentRow: LIVE_COMMENT }) - const { POST } = await import('@/app/api/comments/[id]/restore/route') - const res = await POST(makeRequest(), params()) - expect(res.status).toBe(400) - expect((await res.json()).error).toBe('not_deleted') - }) - - it('returns 400 not_restorable when author-deleted', async () => { - currentFakeClient = makeFakeClient({ commentRow: AUTHOR_DELETED_COMMENT }) - const { POST } = await import('@/app/api/comments/[id]/restore/route') - const res = await POST(makeRequest(), params()) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('not_restorable') - expect(body.detail).toBe('author') - }) - - it('clears state and writes restore_comment audit row on success', async () => { - const client = makeFakeClient() - currentFakeClient = client - const { POST } = await import('@/app/api/comments/[id]/restore/route') - const res = await POST(makeRequest(), params()) - expect(res.status).toBe(200) - - expect(client.updateFn).toHaveBeenCalledWith({ deleted_at: null, deletion_reason: null }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const modCall = (client.modActionsInsertFn.mock.calls as any[][])[0]![0] - expect(modCall.action).toBe('restore_comment') - expect(modCall.target_type).toBe('comment') - expect(modCall.target_id).toBe(COMMENT_ID) - expect(modCall.metadata).toMatchObject({ author_id: AUTHOR_ID }) - }) - }) -}) diff --git a/tests/unit/api/posts-patch.test.ts b/tests/unit/api/posts-patch.test.ts index 4a2c216..14c6ee2 100644 --- a/tests/unit/api/posts-patch.test.ts +++ b/tests/unit/api/posts-patch.test.ts @@ -127,7 +127,7 @@ function postsHandler(opts: { })), } } - // wikilink resolve: select(...like_count, users!inner(username)).eq(slug).is(deleted_at) + // wikilink resolve: select(...published_at, users!inner(username)).eq(slug).is(deleted_at) return { eq: vi.fn(() => ({ is: vi.fn(() => Promise.resolve({ data: wikilinkedPosts, error: null })), diff --git a/tests/unit/api/posts-view.test.ts b/tests/unit/api/posts-view.test.ts deleted file mode 100644 index 2ab7dce..0000000 --- a/tests/unit/api/posts-view.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' - -// --------------------------------------------------------------------------- -// Mock: @/lib/supabase/admin -// --------------------------------------------------------------------------- -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let currentFakeClient: any = {} - -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(() => currentFakeClient), -})) - -// --------------------------------------------------------------------------- -// Captured RPC calls for assertion -// --------------------------------------------------------------------------- -interface CapturedRpc { fnName: string; args: Record } -const capturedRpcs: CapturedRpc[] = [] - -// --------------------------------------------------------------------------- -// Builder helpers -// --------------------------------------------------------------------------- - -function makeViewClient(opts: { rpcError?: { message: string } | null } = {}) { - const { rpcError = null } = opts - return { - rpc: vi.fn((fnName: string, args: Record) => { - capturedRpcs.push({ fnName, args }) - return Promise.resolve({ data: null, error: rpcError }) - }), - } -} - -// --------------------------------------------------------------------------- -// Request / context factory -// --------------------------------------------------------------------------- - -function makeRequest(postId: string) { - return new Request(`http://test/api/posts/${postId}/view`, { - method: 'POST', - headers: { Origin: 'http://localhost:3010' }, - }) -} - -function makeContext(id: string) { - return { params: Promise.resolve({ id }) } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('POST /api/posts/[id]/view — happy path', () => { - beforeEach(() => { - capturedRpcs.length = 0 - currentFakeClient = makeViewClient() - }) - - it('returns 204 and calls increment_post_view_count with the post id', async () => { - const { POST } = await import('@/app/api/posts/[id]/view/route') - const res = await POST(makeRequest('00000000-0000-0000-0000-000000000001') as never, makeContext('00000000-0000-0000-0000-000000000001')) - - expect(res.status).toBe(204) - // Body must be empty for a 204 - const text = await res.text() - expect(text).toBe('') - - expect(capturedRpcs).toHaveLength(1) - expect(capturedRpcs[0].fnName).toBe('increment_post_view_count') - expect(capturedRpcs[0].args).toEqual({ p_id: '00000000-0000-0000-0000-000000000001' }) - }) -}) - -describe('POST /api/posts/[id]/view — RPC error is swallowed (fire-and-forget)', () => { - beforeEach(() => { - capturedRpcs.length = 0 - currentFakeClient = makeViewClient({ rpcError: { message: 'relation "posts" does not exist' } }) - }) - - it('still returns 204 when the RPC returns an error', async () => { - const { POST } = await import('@/app/api/posts/[id]/view/route') - const res = await POST(makeRequest('00000000-0000-0000-0000-000000000002') as never, makeContext('00000000-0000-0000-0000-000000000002')) - - expect(res.status).toBe(204) - // The RPC was still attempted - expect(capturedRpcs).toHaveLength(1) - expect(capturedRpcs[0].fnName).toBe('increment_post_view_count') - }) -}) - -describe('POST /api/posts/[id]/view — non-uuid id is rejected silently', () => { - beforeEach(() => { - capturedRpcs.length = 0 - currentFakeClient = makeViewClient() - }) - - it('returns 204 for an invalid (non-uuid) id and does NOT call the RPC', async () => { - // Security/w5 (H15): the route now refuses to call the increment RPC - // for non-UUID ids. The response stays 204 so a probing script can't - // tell a malformed id from a real-but-deleted post. - const { POST } = await import('@/app/api/posts/[id]/view/route') - const res = await POST(makeRequest('not-a-uuid') as never, makeContext('not-a-uuid')) - - expect(res.status).toBe(204) - expect(capturedRpcs).toHaveLength(0) - }) -}) diff --git a/tests/unit/api/reports-post.test.ts b/tests/unit/api/reports-post.test.ts index fec211d..43fd3f5 100644 --- a/tests/unit/api/reports-post.test.ts +++ b/tests/unit/api/reports-post.test.ts @@ -26,13 +26,10 @@ vi.mock('@/lib/supabase/admin', () => ({ // Valid RFC 4122 UUIDs — version nibble 4, variant bits 8 const REPORTER_ID = 'aabbccdd-1234-4000-8001-000000000001' const TARGET_POST_ID = 'aabbccdd-1234-4000-8001-000000000002' -const TARGET_COMMENT_ID = 'aabbccdd-1234-4000-8001-000000000003' const TARGET_USER_ID = 'aabbccdd-1234-4000-8001-000000000004' const POST_AUTHOR_ID = 'aabbccdd-1234-4000-8001-000000000005' -const COMMENT_AUTHOR_ID = 'aabbccdd-1234-4000-8001-000000000006' const VALID_POST_ROW = { id: TARGET_POST_ID, author_id: POST_AUTHOR_ID } -const VALID_COMMENT_ROW = { id: TARGET_COMMENT_ID, author_id: COMMENT_AUTHOR_ID } const VALID_USER_ROW = { id: TARGET_USER_ID } const VALID_REPORT_ROW = { id: 'aabbccdd-1234-4000-8001-000000000099' } @@ -54,21 +51,18 @@ function makeQueryChain(result: { data: unknown; error: unknown }) { * Build a fake Supabase client for POST /api/reports tests. * * opts.postRow — what the `posts` table returns for .maybeSingle() - * opts.commentRow — what the `comments` table returns for .maybeSingle() * opts.userRow — what the `users` table returns for .maybeSingle() * opts.dupRow — what the `reports` table returns for the dedup query * opts.insertResult — what the `reports` insert returns for .single() */ function makeFakeClient(opts: { postRow?: unknown - commentRow?: unknown userRow?: unknown dupRow?: unknown insertResult?: { data: unknown; error: unknown } } = {}) { const { postRow = VALID_POST_ROW, - commentRow = VALID_COMMENT_ROW, userRow = VALID_USER_ROW, dupRow = null, insertResult = { data: VALID_REPORT_ROW, error: null }, @@ -83,12 +77,6 @@ function makeFakeClient(opts: { if (table === 'posts') { return makeQueryChain({ data: postRow, error: postRow ? null : { message: 'not found' } }) } - if (table === 'comments') { - return makeQueryChain({ - data: commentRow, - error: commentRow ? null : { message: 'not found' }, - }) - } if (table === 'users') { return makeQueryChain({ data: userRow, @@ -175,6 +163,14 @@ describe('POST /api/reports — 400 invalid_body', () => { expect(body.error).toBe('invalid_body') }) + it('returns 400 when target_type is comment', async () => { + const { POST } = await import('@/app/api/reports/route') + const res = await POST(makeRequest({ target_type: 'comment', target_id: TARGET_POST_ID, reason: 'spam' })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toBe('invalid_body') + }) + it('returns 400 when target_id is not a UUID', async () => { const { POST } = await import('@/app/api/reports/route') const res = await POST(makeRequest({ target_type: 'post', target_id: 'not-a-uuid', reason: 'spam' })) @@ -224,17 +220,6 @@ describe('POST /api/reports — 400 self_report', () => { const body = await res.json() expect(body.error).toBe('self_report') }) - - it('returns 400 when reporting own comment', async () => { - // Make the reporter the comment author - sessionState.value = { user: { id: COMMENT_AUTHOR_ID } } - currentFakeClient = makeFakeClient() - const { POST } = await import('@/app/api/reports/route') - const res = await POST(makeRequest({ target_type: 'comment', target_id: TARGET_COMMENT_ID, reason: 'spam' })) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toBe('self_report') - }) }) describe('POST /api/reports — 404 target_not_found', () => { @@ -251,15 +236,6 @@ describe('POST /api/reports — 404 target_not_found', () => { expect(body.error).toBe('target_not_found') }) - it('returns 404 when comment does not exist', async () => { - currentFakeClient = makeFakeClient({ commentRow: null }) - const { POST } = await import('@/app/api/reports/route') - const res = await POST(makeRequest({ target_type: 'comment', target_id: TARGET_COMMENT_ID, reason: 'spam' })) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBe('target_not_found') - }) - it('returns 404 when user does not exist', async () => { currentFakeClient = makeFakeClient({ userRow: null }) const { POST } = await import('@/app/api/reports/route') @@ -300,14 +276,6 @@ describe('POST /api/reports — 201 happy path', () => { expect(body.id).toBe(VALID_REPORT_ROW.id) }) - it('accepts target_type=comment', async () => { - const { POST } = await import('@/app/api/reports/route') - const res = await POST(makeRequest({ target_type: 'comment', target_id: TARGET_COMMENT_ID, reason: 'harassment' })) - expect(res.status).toBe(201) - const body = await res.json() - expect(body.id).toBe(VALID_REPORT_ROW.id) - }) - it('accepts target_type=user when not self', async () => { const { POST } = await import('@/app/api/reports/route') const res = await POST(makeRequest({ target_type: 'user', target_id: TARGET_USER_ID, reason: 'spam' })) diff --git a/tests/unit/bookmarks/list.test.ts b/tests/unit/bookmarks/list.test.ts deleted file mode 100644 index d9907dc..0000000 --- a/tests/unit/bookmarks/list.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { listUserBookmarks } from '@/lib/bookmarks/list' - -// --------------------------------------------------------------------------- -// Fake client builder helpers -// --------------------------------------------------------------------------- - -type ListResult = { - data: Array> | null - error: unknown -} - -function makeListChain(result: ListResult) { - const chain = { - select: vi.fn(), - eq: vi.fn(), - order: vi.fn(), - limit: vi.fn(() => Promise.resolve(result)), - } - chain.select.mockReturnValue(chain) - chain.eq.mockReturnValue(chain) - chain.order.mockReturnValue(chain) - return chain -} - -function makeClientFrom(chain: ReturnType) { - return { from: vi.fn(() => chain) } -} - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const AUTHOR = { - id: 'author-1', - username: 'alice', - display_name: 'Alice', - avatar_url: 'https://example.com/a.jpg', -} - -const POST = { - id: 'post-1', - type: 'post', - slug: 'first', - title: 'First', - summary: 'Summary', - cover_image_url: null, - published_at: '2026-02-01T00:00:00Z', - view_count: 10, - comment_count: 2, - deleted_at: null, - users: AUTHOR, -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('listUserBookmarks', () => { - it('returns [] when supabase returns no rows', async () => { - const chain = makeListChain({ data: [], error: null }) - const admin = makeClientFrom(chain) - const result = await listUserBookmarks(admin as never, 'user-1') - expect(result).toEqual([]) - expect(admin.from).toHaveBeenCalledWith('bookmarks') - }) - - it('returns [] on a Supabase error', async () => { - const chain = makeListChain({ data: null, error: { message: 'boom' } }) - const admin = makeClientFrom(chain) - const result = await listUserBookmarks(admin as never, 'user-1') - expect(result).toEqual([]) - }) - - it('returns bookmarked posts mapped into BookmarkedPost shape', async () => { - const rows = [ - { created_at: '2026-03-02T00:00:00Z', posts: POST }, - { - created_at: '2026-03-01T00:00:00Z', - posts: { - ...POST, - id: 'post-2', - slug: 'second', - title: 'Second', - users: { ...AUTHOR, id: 'author-2', username: 'bob', display_name: 'Bob' }, - }, - }, - ] - const chain = makeListChain({ data: rows, error: null }) - const admin = makeClientFrom(chain) - const result = await listUserBookmarks(admin as never, 'user-1') - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - id: 'post-1', - type: 'post', - slug: 'first', - title: 'First', - summary: 'Summary', - cover_image_url: null, - published_at: '2026-02-01T00:00:00Z', - view_count: 10, - comment_count: 2, - bookmarked_at: '2026-03-02T00:00:00Z', - author: { - id: 'author-1', - username: 'alice', - display_name: 'Alice', - avatar_url: 'https://example.com/a.jpg', - }, - }) - expect(result[1].author.username).toBe('bob') - - // Verify the chain filters and ordering - expect(chain.eq).toHaveBeenCalledWith('user_id', 'user-1') - expect(chain.order).toHaveBeenCalledWith('created_at', { ascending: false }) - expect(chain.limit).toHaveBeenCalledWith(100) - }) - - it('filters out rows whose joined post is null (hard-deleted)', async () => { - const rows = [ - { created_at: '2026-03-02T00:00:00Z', posts: null }, - { created_at: '2026-03-01T00:00:00Z', posts: POST }, - ] - const chain = makeListChain({ data: rows, error: null }) - const admin = makeClientFrom(chain) - const result = await listUserBookmarks(admin as never, 'user-1') - expect(result).toHaveLength(1) - expect(result[0].id).toBe('post-1') - }) - - it('filters out soft-deleted posts', async () => { - const rows = [ - { - created_at: '2026-03-02T00:00:00Z', - posts: { ...POST, deleted_at: '2026-03-03T00:00:00Z' }, - }, - { created_at: '2026-03-01T00:00:00Z', posts: POST }, - ] - const chain = makeListChain({ data: rows, error: null }) - const admin = makeClientFrom(chain) - const result = await listUserBookmarks(admin as never, 'user-1') - expect(result).toHaveLength(1) - expect(result[0].id).toBe('post-1') - }) - - it('coerces null comment_count to 0', async () => { - const rows = [ - { created_at: '2026-03-02T00:00:00Z', posts: { ...POST, comment_count: null } }, - ] - const chain = makeListChain({ data: rows, error: null }) - const admin = makeClientFrom(chain) - const result = await listUserBookmarks(admin as never, 'user-1') - expect(result[0].comment_count).toBe(0) - }) -}) diff --git a/tests/unit/bookmarks/page.test.tsx b/tests/unit/bookmarks/page.test.tsx deleted file mode 100644 index 27f5eb7..0000000 --- a/tests/unit/bookmarks/page.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import React from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { BookmarkedPost } from '@/lib/bookmarks/list' - -// --------------------------------------------------------------------------- -// Module mocks — declared before any imports that trigger them -// --------------------------------------------------------------------------- - -vi.mock('@/lib/auth', () => ({ - getSession: vi.fn(), -})) -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(() => ({})), -})) -vi.mock('@/lib/bookmarks/list', () => ({ - listUserBookmarks: vi.fn(), -})) -vi.mock('next/navigation', () => ({ - redirect: vi.fn((url: string) => { - throw new Error(`NEXT_REDIRECT:${url}`) - }), -})) - -// Stub ProfilePostCard so we can recognise rendered cards in the tree. -vi.mock('@/components/profile/ProfilePostCard', () => ({ - ProfilePostCard: ({ username, post }: { username: string; post: { id: string; title: string } }) => - React.createElement( - 'div', - { 'data-testid': 'profile-post-card', 'data-username': username, 'data-post-id': post.id }, - post.title, - ), -})) - -// --------------------------------------------------------------------------- -// Import after mocks are registered -// --------------------------------------------------------------------------- - -import { getSession } from '@/lib/auth' -import { listUserBookmarks } from '@/lib/bookmarks/list' -import { redirect } from 'next/navigation' -import { ProfilePostCard } from '@/components/profile/ProfilePostCard' -import BookmarksPage from '@/app/bookmarks/page' - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function collectText(node: React.ReactNode): string { - if (node == null || node === false || node === true) return '' - if (typeof node === 'string' || typeof node === 'number') return String(node) - if (Array.isArray(node)) return node.map(collectText).join('') - if (!React.isValidElement(node)) return '' - const props = node.props as Record - return collectText(props.children as React.ReactNode) -} - -function findAllByComponentType( - node: React.ReactNode, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - target: (...args: any[]) => any, -): React.ReactElement[] { - const out: React.ReactElement[] = [] - function walk(n: React.ReactNode) { - if (n == null || n === false || n === true) return - if (Array.isArray(n)) { - n.forEach(walk) - return - } - if (!React.isValidElement(n)) return - if (n.type === target) out.push(n) - const props = n.props as Record - walk(props.children as React.ReactNode) - } - walk(node) - return out -} - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const BASE_BOOKMARK: BookmarkedPost = { - id: 'post-1', - type: 'post', - slug: 'first', - title: 'First Post', - summary: 'A summary', - cover_image_url: null, - published_at: '2026-02-01T00:00:00Z', - view_count: 5, - comment_count: 0, - bookmarked_at: '2026-03-01T00:00:00Z', - author: { - id: 'author-1', - username: 'alice', - display_name: 'Alice', - avatar_url: null, - }, -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('BookmarksPage', () => { - beforeEach(() => { - vi.mocked(getSession).mockReset() - vi.mocked(listUserBookmarks).mockReset() - vi.mocked(redirect).mockClear() - }) - - it('redirects anon users to /auth/signin?callbackUrl=/bookmarks', async () => { - vi.mocked(getSession).mockResolvedValue(null) - - await expect(BookmarksPage()).rejects.toThrow( - 'NEXT_REDIRECT:/auth/signin?callbackUrl=/bookmarks', - ) - - expect(redirect).toHaveBeenCalledWith('/auth/signin?callbackUrl=/bookmarks') - expect(listUserBookmarks).not.toHaveBeenCalled() - }) - - it('renders empty-state copy when signed in but has no bookmarks', async () => { - vi.mocked(getSession).mockResolvedValue({ - user: { id: 'user-1', name: 'Alice', email: 'alice@example.com' }, - expires: '2099-12-31T23:59:59.000Z', - }) - vi.mocked(listUserBookmarks).mockResolvedValue([]) - - const tree = await BookmarksPage() - const text = collectText(tree) - - expect(text).toContain('Your bookmarks') - expect(text).toContain('Bookmark posts to revisit them here.') - expect(findAllByComponentType(tree, ProfilePostCard)).toHaveLength(0) - }) - - it('renders one ProfilePostCard per bookmark, using the AUTHOR username', async () => { - vi.mocked(getSession).mockResolvedValue({ - user: { id: 'user-1', name: 'Alice', email: 'alice@example.com' }, - expires: '2099-12-31T23:59:59.000Z', - }) - vi.mocked(listUserBookmarks).mockResolvedValue([BASE_BOOKMARK]) - - const tree = await BookmarksPage() - const cards = findAllByComponentType(tree, ProfilePostCard) - - expect(cards).toHaveLength(1) - const props = cards[0].props as { username: string; post: { id: string } } - // The card MUST receive the AUTHOR's username (not the viewer's), so the - // post link routes to ///. - expect(props.username).toBe('alice') - expect(props.post.id).toBe('post-1') - }) -}) diff --git a/tests/unit/comments/abuse.test.ts b/tests/unit/comments/abuse.test.ts deleted file mode 100644 index daeabf9..0000000 --- a/tests/unit/comments/abuse.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - HONEYPOT_FIELD, - isHoneypotTripped, - isUrlHeavy, -} from '@/lib/comments/abuse' - -describe('HONEYPOT_FIELD', () => { - it('is the literal "_h"', () => { - expect(HONEYPOT_FIELD).toBe('_h') - }) -}) - -describe('isHoneypotTripped', () => { - it('returns false for non-object bodies', () => { - expect(isHoneypotTripped(null)).toBe(false) - expect(isHoneypotTripped(undefined)).toBe(false) - expect(isHoneypotTripped('string')).toBe(false) - expect(isHoneypotTripped(42)).toBe(false) - }) - - it('returns false when _h is absent', () => { - expect(isHoneypotTripped({})).toBe(false) - expect(isHoneypotTripped({ body: 'comment' })).toBe(false) - }) - - it('returns false when _h is empty string', () => { - expect(isHoneypotTripped({ _h: '' })).toBe(false) - }) - - it('returns false when _h is null or undefined', () => { - expect(isHoneypotTripped({ _h: null })).toBe(false) - expect(isHoneypotTripped({ _h: undefined })).toBe(false) - }) - - it('returns true when _h has any non-empty string', () => { - expect(isHoneypotTripped({ _h: 'spam' })).toBe(true) - expect(isHoneypotTripped({ _h: ' ' })).toBe(true) - }) - - it('returns true when _h is a non-empty array', () => { - expect(isHoneypotTripped({ _h: ['x'] })).toBe(true) - expect(isHoneypotTripped({ _h: [] })).toBe(false) - }) - - it('returns true when _h is a non-zero number or true boolean', () => { - expect(isHoneypotTripped({ _h: 1 })).toBe(true) - expect(isHoneypotTripped({ _h: 0 })).toBe(false) - expect(isHoneypotTripped({ _h: true })).toBe(true) - expect(isHoneypotTripped({ _h: false })).toBe(false) - }) - - it('returns true when _h is a non-empty object', () => { - expect(isHoneypotTripped({ _h: { a: 1 } })).toBe(true) - expect(isHoneypotTripped({ _h: {} })).toBe(false) - }) -}) - -describe('isUrlHeavy', () => { - it('returns false for empty/whitespace input', () => { - expect(isUrlHeavy('')).toBe(false) - expect(isUrlHeavy(' ')).toBe(false) - }) - - it('returns false for non-url text', () => { - expect(isUrlHeavy('this is a normal comment with no links')).toBe(false) - }) - - it('returns false when URLs are <= 50% of tokens', () => { - // 1 url out of 4 tokens = 25% - expect(isUrlHeavy('check this https://example.com cool right')).toBe(false) - }) - - it('returns true when URLs exceed 50% of tokens', () => { - // 3 urls out of 4 tokens = 75% - expect( - isUrlHeavy('https://a.com https://b.com https://c.com hi'), - ).toBe(true) - }) - - it('counts www.* tokens as URLs', () => { - // 2 urls out of 3 tokens = 66% - expect(isUrlHeavy('www.a.com www.b.com text')).toBe(true) - }) - - it('counts scheme-only :// tokens as URLs', () => { - // 2 urls (custom://, ftp://x) out of 3 tokens - expect(isUrlHeavy('custom://a ftp://x.com text')).toBe(true) - }) - - it('treats single-url comments (1/1) as URL-heavy', () => { - expect(isUrlHeavy('https://only.example.com')).toBe(true) - }) -}) diff --git a/tests/unit/comments/depth.test.ts b/tests/unit/comments/depth.test.ts deleted file mode 100644 index 5819ac3..0000000 --- a/tests/unit/comments/depth.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { getNewCommentDepth } from '@/lib/comments/depth' -import type { SupabaseClient } from '@supabase/supabase-js' - -function makeAdmin(rpcImpl: ReturnType) { - return { rpc: rpcImpl } as unknown as SupabaseClient -} - -describe('getNewCommentDepth', () => { - it('returns 1 when parent is null (root comment)', async () => { - const rpc = vi.fn() - const admin = makeAdmin(rpc) - await expect(getNewCommentDepth(admin, null)).resolves.toBe(1) - expect(rpc).not.toHaveBeenCalled() - }) - - it('returns parent_depth + 1 for a non-null parent', async () => { - const rpc = vi.fn(async () => ({ data: 3, error: null })) - const admin = makeAdmin(rpc) - await expect(getNewCommentDepth(admin, 'parent-uuid')).resolves.toBe(4) - expect(rpc).toHaveBeenCalledWith('comment_depth_for_parent', { - p_parent: 'parent-uuid', - }) - }) - - it('returns 2 when parent is a root (depth 1)', async () => { - const rpc = vi.fn(async () => ({ data: 1, error: null })) - const admin = makeAdmin(rpc) - await expect(getNewCommentDepth(admin, 'root-uuid')).resolves.toBe(2) - }) - - it('throws when the RPC errors', async () => { - const rpc = vi.fn(async () => ({ - data: null, - error: { message: 'rpc failed' }, - })) - const admin = makeAdmin(rpc) - await expect(getNewCommentDepth(admin, 'parent-uuid')).rejects.toThrow( - /rpc failed/, - ) - }) - - it('throws parent_not_found when the parent UUID does not exist', async () => { - // RPC returns NULL (via NULLIF on count=0) when p_parent matches no row. - // Without the explicit guard, getNewCommentDepth would silently treat a - // bogus UUID as a root comment (NULL + 1 → NaN or 1). - const rpc = vi.fn(async () => ({ data: null, error: null })) - const admin = makeAdmin(rpc) - await expect(getNewCommentDepth(admin, 'bogus-uuid')).rejects.toThrow( - /parent_not_found/, - ) - }) -}) diff --git a/tests/unit/comments/format-time.test.ts b/tests/unit/comments/format-time.test.ts deleted file mode 100644 index 8dfff4d..0000000 --- a/tests/unit/comments/format-time.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { formatRelativeTime } from '@/lib/comments/format-time' - -const NOW = new Date('2026-05-30T12:00:00Z') - -describe('formatRelativeTime', () => { - it('returns "just now" for < 60s', () => { - expect(formatRelativeTime('2026-05-30T11:59:30Z', NOW)).toBe('just now') - }) - - it('returns minutes for < 1h', () => { - expect(formatRelativeTime('2026-05-30T11:55:00Z', NOW)).toBe('5m ago') - }) - - it('returns hours for < 1d', () => { - expect(formatRelativeTime('2026-05-30T09:00:00Z', NOW)).toBe('3h ago') - }) - - it('returns days for < 30d', () => { - expect(formatRelativeTime('2026-05-28T12:00:00Z', NOW)).toBe('2d ago') - }) - - it('falls back to an absolute date for >= 30d', () => { - const r = formatRelativeTime('2026-04-01T12:00:00Z', NOW) - expect(r).toMatch(/Apr 1, 2026/) - }) - - it('returns empty string for unparseable input', () => { - expect(formatRelativeTime('not-a-date', NOW)).toBe('') - }) -}) diff --git a/tests/unit/comments/sanitize.test.ts b/tests/unit/comments/sanitize.test.ts deleted file mode 100644 index 60d3881..0000000 --- a/tests/unit/comments/sanitize.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { sanitizeCommentBody } from '@/lib/comments/sanitize' - -describe('sanitizeCommentBody', () => { - it('strips script tags and their content tags', () => { - expect(sanitizeCommentBody('hi')).toBe('alert(1)hi') - }) - - it('strips inline formatting tags', () => { - expect(sanitizeCommentBody('x')).toBe('x') - }) - - it('leaves plain text untouched', () => { - expect(sanitizeCommentBody('hello')).toBe('hello') - }) - - it('preserves newlines between paragraphs', () => { - expect(sanitizeCommentBody('a\n\nb')).toBe('a\n\nb') - }) - - it('trims leading and trailing whitespace', () => { - expect(sanitizeCommentBody(' hello ')).toBe('hello') - }) - - it('strips multiple tags in one string', () => { - expect(sanitizeCommentBody('

one

two

')).toBe('onetwo') - }) - - it('strips tags with attributes', () => { - expect(sanitizeCommentBody('link')).toBe('link') - }) - - it('collapses runs of spaces and tabs but preserves newlines', () => { - expect(sanitizeCommentBody('a b\nc')).toBe('a b\nc') - }) -}) diff --git a/tests/unit/comments/schema.test.ts b/tests/unit/comments/schema.test.ts deleted file mode 100644 index 359457c..0000000 --- a/tests/unit/comments/schema.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { CommentCreateBody, CommentPatchBody } from '@/lib/comments/schema' - -const VALID_UUID = '11111111-1111-4111-8111-111111111111' -const PARENT_UUID = '22222222-2222-4222-8222-222222222222' - -describe('CommentCreateBody', () => { - it('accepts a minimal valid payload', () => { - const parsed = CommentCreateBody.safeParse({ - post_id: VALID_UUID, - body: 'a real comment', - }) - expect(parsed.success).toBe(true) - }) - - it('accepts a payload with parent_comment_id as a uuid', () => { - const parsed = CommentCreateBody.safeParse({ - post_id: VALID_UUID, - parent_comment_id: PARENT_UUID, - body: 'reply', - }) - expect(parsed.success).toBe(true) - }) - - it('accepts parent_comment_id as null', () => { - const parsed = CommentCreateBody.safeParse({ - post_id: VALID_UUID, - parent_comment_id: null, - body: 'root', - }) - expect(parsed.success).toBe(true) - }) - - it('accepts when parent_comment_id is omitted entirely', () => { - const parsed = CommentCreateBody.safeParse({ - post_id: VALID_UUID, - body: 'root', - }) - expect(parsed.success).toBe(true) - }) - - it('rejects body shorter than 1 character', () => { - const parsed = CommentCreateBody.safeParse({ - post_id: VALID_UUID, - body: '', - }) - expect(parsed.success).toBe(false) - }) - - it('rejects body longer than 5000 characters', () => { - const parsed = CommentCreateBody.safeParse({ - post_id: VALID_UUID, - body: 'a'.repeat(5001), - }) - expect(parsed.success).toBe(false) - }) - - it('rejects when post_id is not a uuid', () => { - const parsed = CommentCreateBody.safeParse({ - post_id: 'not-a-uuid', - body: 'hi', - }) - expect(parsed.success).toBe(false) - }) - - it('rejects extra fields (.strict)', () => { - const parsed = CommentCreateBody.safeParse({ - post_id: VALID_UUID, - body: 'hi', - malicious: 'value', - }) - expect(parsed.success).toBe(false) - }) -}) - -describe('CommentPatchBody', () => { - it('accepts a valid body', () => { - const parsed = CommentPatchBody.safeParse({ body: 'edited' }) - expect(parsed.success).toBe(true) - }) - - it('rejects an empty body', () => { - const parsed = CommentPatchBody.safeParse({ body: '' }) - expect(parsed.success).toBe(false) - }) - - it('rejects bodies longer than 5000 chars', () => { - const parsed = CommentPatchBody.safeParse({ body: 'a'.repeat(5001) }) - expect(parsed.success).toBe(false) - }) - - it('rejects extra fields (.strict)', () => { - const parsed = CommentPatchBody.safeParse({ - body: 'edited', - post_id: VALID_UUID, - }) - expect(parsed.success).toBe(false) - }) -}) diff --git a/tests/unit/comments/tree.test.ts b/tests/unit/comments/tree.test.ts deleted file mode 100644 index e1b6a34..0000000 --- a/tests/unit/comments/tree.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { buildCommentTree } from '@/lib/comments/tree' -import type { FlatComment } from '@/lib/comments/tree' - -interface TestComment extends FlatComment { - body: string - deleted_at: string | null -} - -function mk( - id: string, - parent: string | null, - created: string, - extras: Partial = {}, -): TestComment { - return { - id, - parent_comment_id: parent, - created_at: created, - body: extras.body ?? `body of ${id}`, - deleted_at: extras.deleted_at ?? null, - } -} - -describe('buildCommentTree', () => { - it('returns an empty array for empty input', () => { - expect(buildCommentTree([])).toEqual([]) - }) - - it('returns top-level comments as roots, ordered by created_at ASC', () => { - const flat = [ - mk('c2', null, '2026-05-02T00:00:00Z'), - mk('c1', null, '2026-05-01T00:00:00Z'), - mk('c3', null, '2026-05-03T00:00:00Z'), - ] - const tree = buildCommentTree(flat) - expect(tree.map((n) => n.comment.id)).toEqual(['c1', 'c2', 'c3']) - expect(tree.map((n) => n.depth)).toEqual([1, 1, 1]) - }) - - it('nests children under their parent and sorts siblings by created_at ASC', () => { - const flat = [ - mk('root', null, '2026-05-01T00:00:00Z'), - mk('child-b', 'root', '2026-05-01T02:00:00Z'), - mk('child-a', 'root', '2026-05-01T01:00:00Z'), - ] - const tree = buildCommentTree(flat) - expect(tree).toHaveLength(1) - expect(tree[0].children.map((c) => c.comment.id)).toEqual([ - 'child-a', - 'child-b', - ]) - expect(tree[0].depth).toBe(1) - expect(tree[0].children[0].depth).toBe(2) - expect(tree[0].children[1].depth).toBe(2) - }) - - it('assigns correct depths down a deep chain', () => { - const flat = [ - mk('d1', null, '2026-05-01T00:00:00Z'), - mk('d2', 'd1', '2026-05-01T00:01:00Z'), - mk('d3', 'd2', '2026-05-01T00:02:00Z'), - mk('d4', 'd3', '2026-05-01T00:03:00Z'), - mk('d5', 'd4', '2026-05-01T00:04:00Z'), - ] - const tree = buildCommentTree(flat) - expect(tree).toHaveLength(1) - let node = tree[0] - const depths = [node.depth] - while (node.children.length > 0) { - node = node.children[0] - depths.push(node.depth) - } - expect(depths).toEqual([1, 2, 3, 4, 5]) - }) - - it('preserves children of a soft-deleted parent', () => { - const flat = [ - mk('parent', null, '2026-05-01T00:00:00Z', { - deleted_at: '2026-05-01T01:00:00Z', - }), - mk('child', 'parent', '2026-05-01T00:30:00Z'), - ] - const tree = buildCommentTree(flat) - expect(tree).toHaveLength(1) - expect(tree[0].comment.id).toBe('parent') - expect(tree[0].children).toHaveLength(1) - expect(tree[0].children[0].comment.id).toBe('child') - }) - - it('promotes orphans (parent_comment_id refers to a missing row) to roots', () => { - const flat = [ - mk('orphan', 'missing-parent-id', '2026-05-01T00:00:00Z'), - ] - const tree = buildCommentTree(flat) - expect(tree).toHaveLength(1) - expect(tree[0].comment.id).toBe('orphan') - expect(tree[0].depth).toBe(1) - }) - - it('processes children in either input order (parent-before-child or child-before-parent)', () => { - // child appears before parent in input — builder must still attach - // it correctly using the pre-allocated id map. - const flat = [ - mk('child', 'root', '2026-05-01T01:00:00Z'), - mk('root', null, '2026-05-01T00:00:00Z'), - ] - const tree = buildCommentTree(flat) - expect(tree).toHaveLength(1) - expect(tree[0].comment.id).toBe('root') - expect(tree[0].children).toHaveLength(1) - expect(tree[0].children[0].comment.id).toBe('child') - }) -}) diff --git a/tests/unit/components/comments-section.test.tsx b/tests/unit/components/comments-section.test.tsx deleted file mode 100644 index 4726089..0000000 --- a/tests/unit/components/comments-section.test.tsx +++ /dev/null @@ -1,271 +0,0 @@ -/** - * CommentsSection server-component tests. - * - * Server components return a React tree synchronously after their async - * work, so we await the call and then mount the result with React Testing - * Library to inspect the DOM. The Supabase admin client is replaced with a - * vi.fn that returns a chainable stub matching the - * admin.from(...).select(...).eq(...).order(...).limit(...) - * shape used by CommentsSection. - */ -import React from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, within } from '@testing-library/react' - -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(), -})) - -const sessionState: { value: { user?: { id: string } } | null } = { value: null } -const adminState: { value: boolean } = { value: false } - -vi.mock('@/lib/auth', () => ({ - getSession: vi.fn(async () => sessionState.value), - resolveIsAdmin: vi.fn(async () => adminState.value), -})) - -vi.mock('next/link', () => ({ - default: ({ - href, - children, - className, - }: { - href: string - children: React.ReactNode - className?: string - }) => React.createElement('a', { href, className }, children), -})) - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: vi.fn() }), - usePathname: () => '/test-post', -})) - -import { createAdminSupabaseClient } from '@/lib/supabase/admin' -import { CommentsSection } from '@/components/post/CommentsSection' - -interface RawCommentRow { - id: string - post_id: string - parent_comment_id: string | null - body: string - author_id: string - created_at: string - edited_at: string | null - deleted_at: string | null - deletion_reason: 'author' | 'moderation' | null - users: { username: string; display_name: string; avatar_url: string | null } | null -} - -function fakeAdmin(rows: RawCommentRow[] | null, error: unknown = null) { - const chain = { - select: vi.fn(), - eq: vi.fn(), - order: vi.fn(), - limit: vi.fn(() => Promise.resolve({ data: rows, error })), - } - chain.select.mockReturnValue(chain) - chain.eq.mockReturnValue(chain) - chain.order.mockReturnValue(chain) - return { from: vi.fn(() => chain) } -} - -function comment(overrides: Partial): RawCommentRow { - return { - id: 'cid', - post_id: 'post-1', - parent_comment_id: null, - body: 'hello', - author_id: 'user-x', - created_at: '2026-05-01T00:00:00Z', - edited_at: null, - deleted_at: null, - deletion_reason: null, - users: { username: 'somebody', display_name: 'Somebody', avatar_url: null }, - ...overrides, - } -} - -beforeEach(() => { - sessionState.value = null - adminState.value = false - vi.mocked(createAdminSupabaseClient).mockReset() -}) - -describe('', () => { - it('renders the empty state when there are no comments', async () => { - vi.mocked(createAdminSupabaseClient).mockReturnValue( - fakeAdmin([]) as never, - ) - sessionState.value = null - const tree = await CommentsSection({ postId: 'post-1' }) - render(tree) - expect(screen.getByText(/be the first to comment/i)).toBeInTheDocument() - expect( - screen.getByRole('heading', { name: /^comments$/i }), - ).toBeInTheDocument() - }) - - it('shows the "Sign in to comment" affordance for anonymous viewers with no comments', async () => { - vi.mocked(createAdminSupabaseClient).mockReturnValue( - fakeAdmin([]) as never, - ) - sessionState.value = null - const tree = await CommentsSection({ postId: 'post-1' }) - render(tree) - expect( - screen.getByRole('link', { name: /sign in/i }), - ).toHaveAttribute('href', '/auth/signin') - }) - - it('renders 3 threaded comments and counts only non-deleted in the heading', async () => { - const rows: RawCommentRow[] = [ - comment({ - id: 'c1', - body: 'root one', - created_at: '2026-05-01T00:00:00Z', - }), - comment({ - id: 'c2', - parent_comment_id: 'c1', - body: 'reply to c1', - created_at: '2026-05-01T00:01:00Z', - }), - comment({ - id: 'c3', - body: 'root two', - created_at: '2026-05-01T00:02:00Z', - }), - ] - vi.mocked(createAdminSupabaseClient).mockReturnValue( - fakeAdmin(rows) as never, - ) - sessionState.value = null - - const tree = await CommentsSection({ postId: 'post-1' }) - const { container } = render(tree) - expect( - screen.getByRole('heading', { name: /3 comments/i }), - ).toBeInTheDocument() - expect(screen.getByText('root one')).toBeInTheDocument() - expect(screen.getByText('reply to c1')).toBeInTheDocument() - expect(screen.getByText('root two')).toBeInTheDocument() - - // Threading structure: the reply must render at depth 2 (root = depth 1). - const replyNode = container.querySelector('[data-depth="2"]') - expect(replyNode).not.toBeNull() - expect(replyNode?.textContent).toContain('reply to c1') - }) - - it('renders the "Sign in to comment" affordance for anonymous viewers with comments', async () => { - const rows: RawCommentRow[] = [ - comment({ id: 'c1', body: 'hi' }), - ] - vi.mocked(createAdminSupabaseClient).mockReturnValue( - fakeAdmin(rows) as never, - ) - sessionState.value = null - - const tree = await CommentsSection({ postId: 'post-1' }) - render(tree) - expect(screen.getByText(/sign in to comment/i)).toBeInTheDocument() - expect( - screen.queryByRole('button', { name: /reply/i }), - ).not.toBeInTheDocument() - }) - - it('shows Edit + Delete for the author on their own comment', async () => { - const rows: RawCommentRow[] = [ - comment({ - id: 'c1', - author_id: 'me', - body: 'mine', - // Recent — within the 24h edit window - created_at: new Date().toISOString(), - }), - ] - vi.mocked(createAdminSupabaseClient).mockReturnValue( - fakeAdmin(rows) as never, - ) - sessionState.value = { user: { id: 'me' } } - - const tree = await CommentsSection({ postId: 'post-1' }) - render(tree) - expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument() - expect( - screen.getByRole('button', { name: /^delete$/i }), - ).toBeInTheDocument() - }) - - it('shows only Reply for a signed-in non-author', async () => { - const rows: RawCommentRow[] = [ - comment({ id: 'c1', author_id: 'someone-else', body: 'theirs' }), - ] - vi.mocked(createAdminSupabaseClient).mockReturnValue( - fakeAdmin(rows) as never, - ) - sessionState.value = { user: { id: 'me' } } - adminState.value = false - - const tree = await CommentsSection({ postId: 'post-1' }) - render(tree) - expect(screen.getByRole('button', { name: /reply/i })).toBeInTheDocument() - expect( - screen.queryByRole('button', { name: /^edit$/i }), - ).not.toBeInTheDocument() - expect( - screen.queryByRole('button', { name: /^delete$/i }), - ).not.toBeInTheDocument() - }) - - it('renders a [removed] placeholder for a soft-deleted parent and still renders its children', async () => { - const rows: RawCommentRow[] = [ - comment({ - id: 'p1', - body: 'real body but should be hidden', - deleted_at: '2026-05-01T00:30:00Z', - deletion_reason: 'author', - }), - comment({ - id: 'c1', - parent_comment_id: 'p1', - body: 'still-visible child', - created_at: '2026-05-01T00:01:00Z', - }), - ] - vi.mocked(createAdminSupabaseClient).mockReturnValue( - fakeAdmin(rows) as never, - ) - sessionState.value = null - - const tree = await CommentsSection({ postId: 'post-1' }) - render(tree) - // Heading counts only non-deleted rows - expect( - screen.getByRole('heading', { name: /1 comment/i }), - ).toBeInTheDocument() - // Child is rendered - expect(screen.getByText('still-visible child')).toBeInTheDocument() - // Deleted body is replaced with placeholder - expect( - screen.queryByText('real body but should be hidden'), - ).not.toBeInTheDocument() - expect(screen.getByText(/\[removed by author\]/i)).toBeInTheDocument() - }) -}) - -describe(' — anon listing shows sign-in link inline', () => { - it('renders a sign-in link inside the thread when comments exist and viewer is anon', async () => { - const rows: RawCommentRow[] = [ - comment({ id: 'c1', body: 'first' }), - ] - vi.mocked(createAdminSupabaseClient).mockReturnValue( - fakeAdmin(rows) as never, - ) - sessionState.value = null - const tree = await CommentsSection({ postId: 'post-1' }) - const { container } = render(tree) - const links = within(container).getAllByRole('link', { name: /sign in/i }) - expect(links.length).toBeGreaterThanOrEqual(1) - }) -}) diff --git a/tests/unit/components/follow-button.test.tsx b/tests/unit/components/follow-button.test.tsx deleted file mode 100644 index 1a5617e..0000000 --- a/tests/unit/components/follow-button.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/** - * — follow toggle tests. - * - * Mirrors the LikeButton test pattern: stub `useRouter` so anon-click - * routing is observable, stub global.fetch per-test so we can assert - * method + reconciled state. - * - * Phase 13 a11y note: the button label is stable ("Follow @") - * per the ARIA Authoring Practices toggle pattern. Toggle state lives in - * `aria-pressed`, not in the accessible name, so screen readers don't - * announce conflicting verbs ("Unfollow, pressed"). Assertions below - * check aria-pressed, not the label, for state changes. - */ -import React from 'react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' - -const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockPush }), -})) - -import { FollowButton } from '@/components/profile/FollowButton' - -beforeEach(() => { - mockPush.mockReset() - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.stubGlobal('fetch', vi.fn()) -}) - -afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -describe('', () => { - it('anon click routes to /auth/signin with the encoded callbackUrl and does NOT call fetch', () => { - const mockFetch = vi.fn() - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /follow @alice/i })) - - expect(mockPush).toHaveBeenCalledWith('/auth/signin?callbackUrl=%2Falice') - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('signed-in click on not-following → optimistic flip + POST, reconciles from server', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ following: true, follower_count: 6 }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /follow @alice/i }) - expect(btn).toHaveAttribute('aria-pressed', 'false') - fireEvent.click(btn) - - // Optimistic: aria-pressed flips to true immediately; the visible - // text switches from "Follow" to "Following" (still the same label). - expect(screen.getByRole('button', { name: /follow @alice/i })).toHaveAttribute( - 'aria-pressed', - 'true', - ) - expect(screen.getByRole('button', { name: /follow @alice/i })).toHaveTextContent( - /following/i, - ) - - expect(mockFetch).toHaveBeenCalledWith('/api/follows/user-2', { - method: 'POST', - }) - - await waitFor(() => - expect( - screen.getByRole('button', { name: /follow @alice/i }), - ).not.toBeDisabled(), - ) - }) - - it('signed-in click on following → optimistic flip + DELETE, reconciles', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ following: false, follower_count: 4 }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /follow @alice/i }) - expect(btn).toHaveAttribute('aria-pressed', 'true') - fireEvent.click(btn) - - // Optimistic: aria-pressed flips to false. - expect(screen.getByRole('button', { name: /follow @alice/i })).toHaveAttribute( - 'aria-pressed', - 'false', - ) - expect(mockFetch).toHaveBeenCalledWith('/api/follows/user-2', { - method: 'DELETE', - }) - - await waitFor(() => - expect( - screen.getByRole('button', { name: /follow @alice/i }), - ).not.toBeDisabled(), - ) - }) - - it('reverts optimistic state when fetch returns a non-2xx response', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: 'follow_failed' }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /follow @alice/i })) - - await waitFor(() => - expect( - screen.getByRole('button', { name: /follow @alice/i }), - ).toHaveAttribute('aria-pressed', 'false'), - ) - }) - - it('reverts when fetch throws (network error)', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('offline')) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /follow @alice/i })) - - await waitFor(() => - expect( - screen.getByRole('button', { name: /follow @alice/i }), - ).toHaveAttribute('aria-pressed', 'true'), - ) - }) -}) - -// --------------------------------------------------------------------------- -// Shake-on-revert visual treatment (Phase 13 polish) -// --------------------------------------------------------------------------- - -describe(' shake-on-revert', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('failed click adds .shake-on-revert and removes it after ~400ms', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: 'follow_failed' }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /follow @alice/i }) - expect(btn).not.toHaveClass('shake-on-revert') - - fireEvent.click(btn) - - await vi.waitFor( - () => expect(btn).toHaveClass('shake-on-revert'), - { timeout: 1000 }, - ) - - vi.advanceTimersByTime(401) - expect(btn).not.toHaveClass('shake-on-revert') - }) -}) diff --git a/tests/unit/components/home/left-nav.test.tsx b/tests/unit/components/home/left-nav.test.tsx index 5bda65f..f8a5831 100644 --- a/tests/unit/components/home/left-nav.test.tsx +++ b/tests/unit/components/home/left-nav.test.tsx @@ -3,10 +3,9 @@ * * Coverage: * 1. Active route gets aria-current="page" (exact pathname match). - * 2. Bookmarks renders only when session is authenticated. - * 3. Profile renders only when session is authenticated + username is set. - * 4. Profile href is `/${username}`. - * 5. Bookmarks and Profile are hidden when session is null (unauthenticated). + * 2. Profile renders only when session is authenticated + username is set. + * 3. Profile href is `/${username}`. + * 4. Profile is hidden when session is null (unauthenticated). * * Mocking pattern follows tests/unit/components/nav-auth.test.tsx: * - `next-auth/react` → mock useSession @@ -49,18 +48,8 @@ describe('', () => { expect(homeLink).toHaveAttribute('aria-current', 'page') }) - it('marks the Trending link aria-current="page" when on /trending', () => { - mockUsePathname.mockReturnValue('/trending') - mockUseSession.mockReturnValue({ data: null, status: 'unauthenticated' }) - - render() - - const trendingLink = screen.getByRole('link', { name: 'Trending' }) - expect(trendingLink).toHaveAttribute('aria-current', 'page') - }) - - it('does NOT mark Home as active when on /trending', () => { - mockUsePathname.mockReturnValue('/trending') + it('does NOT mark Home as active when on /tags', () => { + mockUsePathname.mockReturnValue('/tags') mockUseSession.mockReturnValue({ data: null, status: 'unauthenticated' }) render() @@ -71,26 +60,16 @@ describe('', () => { }) describe('unauthenticated state', () => { - it('renders public items (Home, Trending, All tags)', () => { + it('renders public items (Home, All tags)', () => { mockUsePathname.mockReturnValue('/') mockUseSession.mockReturnValue({ data: null, status: 'unauthenticated' }) render() expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Trending' })).toBeInTheDocument() expect(screen.getByRole('link', { name: 'All tags' })).toBeInTheDocument() }) - it('does NOT render Bookmarks when unauthenticated', () => { - mockUsePathname.mockReturnValue('/') - mockUseSession.mockReturnValue({ data: null, status: 'unauthenticated' }) - - render() - - expect(screen.queryByRole('link', { name: 'Bookmarks' })).toBeNull() - }) - it('does NOT render Profile when unauthenticated', () => { mockUsePathname.mockReturnValue('/') mockUseSession.mockReturnValue({ data: null, status: 'unauthenticated' }) @@ -102,18 +81,6 @@ describe('', () => { }) describe('authenticated state', () => { - it('renders Bookmarks when session is present', () => { - mockUsePathname.mockReturnValue('/') - mockUseSession.mockReturnValue({ - status: 'authenticated', - data: { user: { id: '1', name: 'Ada', username: 'ada' } }, - }) - - render() - - expect(screen.getByRole('link', { name: 'Bookmarks' })).toBeInTheDocument() - }) - it('renders Profile when session has a username', () => { mockUsePathname.mockReturnValue('/') mockUseSession.mockReturnValue({ @@ -152,19 +119,6 @@ describe('', () => { expect(profileLink).toHaveAttribute('aria-current', 'page') }) - it('marks Bookmarks aria-current="page" when on /bookmarks', () => { - mockUsePathname.mockReturnValue('/bookmarks') - mockUseSession.mockReturnValue({ - status: 'authenticated', - data: { user: { id: '1', name: 'Ada', username: 'ada' } }, - }) - - render() - - const bookmarksLink = screen.getByRole('link', { name: 'Bookmarks' }) - expect(bookmarksLink).toHaveAttribute('aria-current', 'page') - }) - it('does NOT render Profile if session is present but username is absent', () => { mockUsePathname.mockReturnValue('/') mockUseSession.mockReturnValue({ @@ -174,15 +128,12 @@ describe('', () => { render() - // Bookmarks still renders (session is present) - expect(screen.getByRole('link', { name: 'Bookmarks' })).toBeInTheDocument() - // Profile does NOT render without username expect(screen.queryByRole('link', { name: 'Profile' })).toBeNull() }) }) describe('item order', () => { - it('renders items in locked order: Home → Trending → All tags → Bookmarks → Profile', () => { + it('renders items in locked order: Home, All tags, Profile', () => { mockUsePathname.mockReturnValue('/') mockUseSession.mockReturnValue({ status: 'authenticated', @@ -194,7 +145,7 @@ describe('', () => { const links = screen.getAllByRole('link') const labels = links.map((l) => l.textContent) - expect(labels).toEqual(['Home', 'Trending', 'All tags', 'Bookmarks', 'Profile']) + expect(labels).toEqual(['Home', 'All tags', 'Profile']) }) }) }) diff --git a/tests/unit/components/home/right-sidebar.test.tsx b/tests/unit/components/home/right-sidebar.test.tsx index 2c18fd1..ca457b8 100644 --- a/tests/unit/components/home/right-sidebar.test.tsx +++ b/tests/unit/components/home/right-sidebar.test.tsx @@ -14,18 +14,12 @@ import type { TopPostRow } from '@/lib/feed/top-by-type' // --------------------------------------------------------------------------- vi.mock('@/lib/feed/discovery-cache', () => ({ - cachedTrendingTags: vi.fn(), cachedTopPlaybooks: vi.fn(), cachedTopDives: vi.fn(), })) // Stub child async components with synchronous stubs so RightSidebar // can be rendered in jsdom without needing Suspense streaming. -vi.mock('@/components/home/TrendingTagsRail', () => ({ - TrendingTagsRail: async () => - React.createElement('div', { 'data-testid': 'trending-tags-rail' }), -})) - vi.mock('@/components/home/TopByType', () => ({ TopByType: async ({ type }: { type: string }) => React.createElement('div', { 'data-testid': `top-by-type-${type}` }), @@ -56,7 +50,6 @@ const SAMPLE_POST: TopPostRow = { leading_segment: 'alice', author_username: 'alice', author_display_name: 'Alice', - like_count: 5, } // --------------------------------------------------------------------------- diff --git a/tests/unit/components/home/top-by-type.test.tsx b/tests/unit/components/home/top-by-type.test.tsx index 602063f..d7a784e 100644 --- a/tests/unit/components/home/top-by-type.test.tsx +++ b/tests/unit/components/home/top-by-type.test.tsx @@ -14,7 +14,6 @@ import type { TopPostRow } from '@/lib/feed/top-by-type' // Mocks — must be declared before component import. // --------------------------------------------------------------------------- vi.mock('@/lib/feed/discovery-cache', () => ({ - cachedTrendingTags: vi.fn(), cachedTopPlaybooks: vi.fn(), cachedTopDives: vi.fn(), })) @@ -39,7 +38,6 @@ const PLAYBOOK_ROW: TopPostRow = { leading_segment: 'alice', author_username: 'alice', author_display_name: 'Alice', - like_count: 12, } const DIVE_ROW: TopPostRow = { @@ -50,7 +48,6 @@ const DIVE_ROW: TopPostRow = { leading_segment: 'bob', author_username: 'bob', author_display_name: 'Bob', - like_count: 7, } const ORG_PLAYBOOK_ROW: TopPostRow = { @@ -61,7 +58,6 @@ const ORG_PLAYBOOK_ROW: TopPostRow = { leading_segment: 'acme-org', // org slug author_username: 'carol', author_display_name: 'Carol', - like_count: 4, } // --------------------------------------------------------------------------- @@ -100,28 +96,27 @@ describe('', () => { expect(cachedTopPlaybooks).not.toHaveBeenCalled() }) - it('renders heading "Top playbooks this week" for type=playbook', async () => { + it('renders heading "Recent playbooks" for type=playbook', async () => { vi.mocked(cachedTopPlaybooks).mockResolvedValue([PLAYBOOK_ROW]) const element = await TopByType({ type: 'playbook' }) render(element as React.ReactElement) - expect(screen.getByRole('heading', { name: 'Top playbooks this week' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Recent playbooks' })).toBeInTheDocument() }) - it('renders heading "Top deep dives this week" for type=dive', async () => { + it('renders heading "Recent deep dives" for type=dive', async () => { vi.mocked(cachedTopDives).mockResolvedValue([DIVE_ROW]) const element = await TopByType({ type: 'dive' }) render(element as React.ReactElement) - expect(screen.getByRole('heading', { name: 'Top deep dives this week' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Recent deep dives' })).toBeInTheDocument() }) - it('renders title, author username, and like count for each row', async () => { + it('renders title and author username for each row', async () => { vi.mocked(cachedTopPlaybooks).mockResolvedValue([PLAYBOOK_ROW]) const element = await TopByType({ type: 'playbook' }) render(element as React.ReactElement) expect(screen.getByText('Agent Memory Guide')).toBeInTheDocument() expect(screen.getByText(/@alice/)).toBeInTheDocument() - expect(screen.getByText(/12/)).toBeInTheDocument() }) it('builds href with postUrl using leading_segment (personal post → author username)', async () => { @@ -148,7 +143,7 @@ describe('', () => { vi.mocked(cachedTopPlaybooks).mockResolvedValue([PLAYBOOK_ROW]) const element = await TopByType({ type: 'playbook' }) render(element as React.ReactElement) - expect(screen.getByRole('heading', { name: 'Top playbooks this week' })).toHaveAttribute( + expect(screen.getByRole('heading', { name: 'Recent playbooks' })).toHaveAttribute( 'id', 'top-playbook-heading', ) @@ -158,7 +153,7 @@ describe('', () => { vi.mocked(cachedTopPlaybooks).mockResolvedValue([PLAYBOOK_ROW]) const element = await TopByType({ type: 'playbook', headingId: 'top-playbook-heading-mobile' }) render(element as React.ReactElement) - expect(screen.getByRole('heading', { name: 'Top playbooks this week' })).toHaveAttribute( + expect(screen.getByRole('heading', { name: 'Recent playbooks' })).toHaveAttribute( 'id', 'top-playbook-heading-mobile', ) diff --git a/tests/unit/components/home/trending-tags-rail.test.tsx b/tests/unit/components/home/trending-tags-rail.test.tsx deleted file mode 100644 index 1fe1e06..0000000 --- a/tests/unit/components/home/trending-tags-rail.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * unit tests. - * - * Strategy: mock @/lib/feed/discovery-cache so the component never - * touches unstable_cache or the DB. Await the async server component - * function, then render the returned element with @testing-library/react. - */ -import React from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' -import type { TrendingTag } from '@/lib/feed/trending-tags' - -// --------------------------------------------------------------------------- -// Mock the discovery-cache module BEFORE importing the component. -// --------------------------------------------------------------------------- -vi.mock('@/lib/feed/discovery-cache', () => ({ - cachedTrendingTags: vi.fn(), - cachedTopPlaybooks: vi.fn(), - cachedTopDives: vi.fn(), -})) - -// Mock next/link to a plain so we can assert hrefs. -vi.mock('next/link', () => ({ - default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => - React.createElement('a', { href, ...rest }, children), -})) - -import { cachedTrendingTags } from '@/lib/feed/discovery-cache' -import { TrendingTagsRail } from '@/components/home/TrendingTagsRail' - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('', () => { - beforeEach(() => { - vi.mocked(cachedTrendingTags).mockReset() - }) - - it('returns null (renders nothing) when tags array is empty', async () => { - vi.mocked(cachedTrendingTags).mockResolvedValue([]) - const element = await TrendingTagsRail() - expect(element).toBeNull() - }) - - it('renders one Link per tag with count', async () => { - const tags: TrendingTag[] = [ - { slug: 'memory', name: 'Memory', count: 5 }, - { slug: 'evals', name: 'Evals', count: 3 }, - ] - vi.mocked(cachedTrendingTags).mockResolvedValue(tags) - - const element = await TrendingTagsRail() - render(element as React.ReactElement) - - // Heading must be present. - expect(screen.getByRole('heading', { name: 'Trending tags' })).toBeInTheDocument() - - // One link per tag. - const links = screen.getAllByRole('link') - expect(links).toHaveLength(2) - - // Links should go to /tag/. - expect(links[0]).toHaveAttribute('href', '/tag/memory') - expect(links[1]).toHaveAttribute('href', '/tag/evals') - - // Tag names and counts are rendered. - expect(screen.getByText('#Memory')).toBeInTheDocument() - expect(screen.getByText('5')).toBeInTheDocument() - expect(screen.getByText('#Evals')).toBeInTheDocument() - expect(screen.getByText('3')).toBeInTheDocument() - }) - - it('uses the default headingId "trending-tags-heading" when no prop is passed', async () => { - vi.mocked(cachedTrendingTags).mockResolvedValue([ - { slug: 'memory', name: 'Memory', count: 1 }, - ]) - const element = await TrendingTagsRail() - render(element as React.ReactElement) - expect(screen.getByRole('heading', { name: 'Trending tags' })).toHaveAttribute( - 'id', - 'trending-tags-heading', - ) - }) - - it('uses the provided headingId override (duplicate-id-aria fix)', async () => { - vi.mocked(cachedTrendingTags).mockResolvedValue([ - { slug: 'evals', name: 'Evals', count: 2 }, - ]) - const element = await TrendingTagsRail({ headingId: 'trending-tags-heading-lg' }) - render(element as React.ReactElement) - expect(screen.getByRole('heading', { name: 'Trending tags' })).toHaveAttribute( - 'id', - 'trending-tags-heading-lg', - ) - }) -}) diff --git a/tests/unit/components/nav-auth.test.tsx b/tests/unit/components/nav-auth.test.tsx index a1be3da..4641ec3 100644 --- a/tests/unit/components/nav-auth.test.tsx +++ b/tests/unit/components/nav-auth.test.tsx @@ -7,7 +7,7 @@ * * Coverage focus is the Write button: present + ordered before the avatar * dropdown trigger when authed, absent for anon and loading states. The - * dropdown's contents (Bookmarks / Settings / Sign out) are exercised in + * dropdown's contents (Profile / Settings / Sign out) are exercised in * the dedicated tests once you open it. */ import React from 'react' diff --git a/tests/unit/components/post-engagement.test.tsx b/tests/unit/components/post-engagement.test.tsx deleted file mode 100644 index 14add02..0000000 --- a/tests/unit/components/post-engagement.test.tsx +++ /dev/null @@ -1,385 +0,0 @@ -/** - * + — engagement primitive tests. - * - * Both components are pure client components that mutate local state and - * hit `fetch`. We mock `next/navigation`'s `useRouter` so anon-click - * routing is observable, and stub `global.fetch` per-test so we can - * assert method + payload + reconcile the optimistic state. - */ -import React from 'react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' - -const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockPush }), -})) - -import { LikeButton } from '@/components/post/LikeButton' -import { BookmarkButton } from '@/components/post/BookmarkButton' - -beforeEach(() => { - mockPush.mockReset() - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.stubGlobal('fetch', vi.fn()) -}) - -afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -// --------------------------------------------------------------------------- -// LikeButton -// --------------------------------------------------------------------------- - -describe('', () => { - it('anon click routes to /auth/signin with the encoded callbackUrl and does NOT call fetch', () => { - const mockFetch = vi.fn() - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - fireEvent.click(screen.getByRole('button', { name: /like/i })) - - expect(mockPush).toHaveBeenCalledWith( - '/auth/signin?callbackUrl=%2Falice%2Fpost%2Fhello', - ) - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('signed-in click on unliked → optimistic flip + POST, then reconciles from server', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ liked: true, like_count: 4 }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /like/i }) - expect(btn).toHaveAttribute('aria-pressed', 'false') - fireEvent.click(btn) - - // Optimistic state applied immediately. Label is stable ("Like post" - // per ARIA toggle pattern); pressed-state conveys the toggle. - expect(screen.getByRole('button', { name: /like/i })).toHaveAttribute( - 'aria-pressed', - 'true', - ) - expect(screen.getByText('4')).toBeInTheDocument() - - expect(mockFetch).toHaveBeenCalledWith('/api/likes/post-1', { method: 'POST' }) - - await waitFor(() => - expect( - screen.getByRole('button', { name: /like/i }), - ).not.toBeDisabled(), - ) - expect(screen.getByText('4')).toBeInTheDocument() - }) - - it('signed-in click on liked → optimistic flip + DELETE', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ liked: false, like_count: 9 }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /like/i }) - expect(btn).toHaveAttribute('aria-pressed', 'true') - fireEvent.click(btn) - - // Optimistic flip — label stays "Like post", aria-pressed flips to false. - expect(screen.getByRole('button', { name: /like/i })).toHaveAttribute( - 'aria-pressed', - 'false', - ) - expect(screen.getByText('9')).toBeInTheDocument() - expect(mockFetch).toHaveBeenCalledWith('/api/likes/post-1', { - method: 'DELETE', - }) - - await waitFor(() => - expect(screen.getByRole('button', { name: /like/i })).not.toBeDisabled(), - ) - }) - - it('reverts optimistic state when fetch returns a non-2xx response', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: 'like_failed' }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /like/i })) - - // Wait for the revert (post-fetch, post-finally) - await waitFor(() => - expect( - screen.getByRole('button', { name: /like/i }), - ).toHaveAttribute('aria-pressed', 'false'), - ) - expect(screen.getByText('3')).toBeInTheDocument() - }) - - it('reverts when fetch throws (network error)', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('offline')) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /like/i })) - - await waitFor(() => - expect( - screen.getByRole('button', { name: /like/i }), - ).toHaveAttribute('aria-pressed', 'true'), - ) - expect(screen.getByText('5')).toBeInTheDocument() - }) -}) - -// --------------------------------------------------------------------------- -// BookmarkButton -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Shake-on-revert visual treatment (Phase 13 polish) -// --------------------------------------------------------------------------- - -describe('Phase 13: shake-on-revert visual', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('LikeButton: failed click adds .shake-on-revert and removes it after ~400ms', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: 'like_failed' }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /like/i }) - expect(btn).not.toHaveClass('shake-on-revert') - - fireEvent.click(btn) - - // The revert path runs after the fetch settles. Drain microtasks to - // let the awaited fetch + useEffect schedule the class toggle. - await vi.waitFor( - () => expect(btn).toHaveClass('shake-on-revert'), - { timeout: 1000 }, - ) - - // Advance past the shake duration; class is removed. - vi.advanceTimersByTime(401) - expect(btn).not.toHaveClass('shake-on-revert') - }) - - it('BookmarkButton: failed click adds .shake-on-revert and removes it after ~400ms', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: 'bookmark_failed' }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /^bookmark post$/i }) - expect(btn).not.toHaveClass('shake-on-revert') - - fireEvent.click(btn) - - await vi.waitFor( - () => expect(btn).toHaveClass('shake-on-revert'), - { timeout: 1000 }, - ) - - vi.advanceTimersByTime(401) - expect(btn).not.toHaveClass('shake-on-revert') - }) - - it('LikeButton: successful click does NOT shake', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ liked: true, like_count: 4 }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /like/i }) - fireEvent.click(btn) - - // Settle any pending fetch + microtasks. - await Promise.resolve() - await Promise.resolve() - vi.advanceTimersByTime(500) - expect(btn).not.toHaveClass('shake-on-revert') - }) -}) - -describe('', () => { - it('anon click routes to /auth/signin with the encoded callbackUrl and does NOT call fetch', () => { - const mockFetch = vi.fn() - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - fireEvent.click(screen.getByRole('button', { name: /^bookmark post$/i })) - - expect(mockPush).toHaveBeenCalledWith( - '/auth/signin?callbackUrl=%2Falice%2Fpost%2Fhello', - ) - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('signed-in click on unbookmarked → optimistic flip + POST', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ bookmarked: true }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /^bookmark post$/i }) - expect(btn).toHaveAttribute('aria-pressed', 'false') - fireEvent.click(btn) - - // Stable label per ARIA toggle pattern; aria-pressed conveys state. - expect( - screen.getByRole('button', { name: /^bookmark post$/i }), - ).toHaveAttribute('aria-pressed', 'true') - expect(mockFetch).toHaveBeenCalledWith('/api/bookmarks/post-1', { - method: 'POST', - }) - - await waitFor(() => - expect( - screen.getByRole('button', { name: /^bookmark post$/i }), - ).not.toBeDisabled(), - ) - }) - - it('signed-in click on bookmarked → DELETE and reverts on non-2xx', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: 'unbookmark_failed' }), - }) - vi.stubGlobal('fetch', mockFetch) - - render( - , - ) - - const btn = screen.getByRole('button', { name: /^bookmark post$/i }) - expect(btn).toHaveAttribute('aria-pressed', 'true') - fireEvent.click(btn) - expect(mockFetch).toHaveBeenCalledWith('/api/bookmarks/post-1', { - method: 'DELETE', - }) - - // Server returned 5xx, so we revert back to pressed=true. - await waitFor(() => - expect( - screen.getByRole('button', { name: /^bookmark post$/i }), - ).toHaveAttribute('aria-pressed', 'true'), - ) - }) -}) diff --git a/tests/unit/components/skeleton.test.tsx b/tests/unit/components/skeleton.test.tsx index 8ef25f1..fc5a3d8 100644 --- a/tests/unit/components/skeleton.test.tsx +++ b/tests/unit/components/skeleton.test.tsx @@ -15,7 +15,6 @@ import { describe, it, expect } from 'vitest' import { render } from '@testing-library/react' import { PostCardSkeleton } from '@/components/skeleton/PostCardSkeleton' -import { CommentSkeleton } from '@/components/skeleton/CommentSkeleton' import { ProfileHeaderSkeleton } from '@/components/skeleton/ProfileHeaderSkeleton' import { SearchResultSkeleton } from '@/components/skeleton/SearchResultSkeleton' @@ -31,11 +30,6 @@ const cases: CaseSpec[] = [ render: () => , countProp: { value: 3, itemSelector: '.home-feed__item' }, }, - { - name: 'CommentSkeleton', - render: () => , - countProp: { value: 4, itemSelector: 'article' }, - }, { name: 'ProfileHeaderSkeleton', render: () => , diff --git a/tests/unit/feed/affinity.test.ts b/tests/unit/feed/affinity.test.ts deleted file mode 100644 index 4382ae3..0000000 --- a/tests/unit/feed/affinity.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { - getViewerTagAffinity, - _clearAffinityCacheForTests, -} from '@/lib/feed/affinity' - -// Pin "now" so the exponential recency weight is deterministic. -const NOW = new Date('2026-05-30T12:00:00.000Z') - -function daysAgo(d: number): string { - return new Date(NOW.getTime() - d * 86_400_000).toISOString() -} - -/** - * Minimal Supabase chain stub. - * - * `getViewerTagAffinity` builds three independent chains off - * `db.from(table)` (likes, bookmarks, follows are fired via Promise.all), - * plus an optional 4th (posts authored by followed users). We route each - * by table name to a pre-canned response. Each call to - * `.select / .eq / .order / .limit / .in` is chainable and a no-op - * metadata-wise; the terminal `await` resolves to `{ data, error }`. - * - * `fromSpy` lets tests assert which tables were hit (for cache tests). - */ -type ChainResponse = { data: unknown; error: null | { message: string } } - -function makeStub(responses: Record) { - // Per-table cursor so a table can be queried more than once (e.g. follows - // → followed_ids, then posts authored by those users). - const cursors: Record = {} - const fromSpy = vi.fn<(table: string) => unknown>() - - function chain(table: string) { - const next = (): ChainResponse => { - const r = responses[table] - if (Array.isArray(r)) { - const i = cursors[table] ?? 0 - cursors[table] = i + 1 - return r[i] ?? { data: [], error: null } - } - return r ?? { data: [], error: null } - } - - const builder: { - select: (...args: unknown[]) => typeof builder - eq: (...args: unknown[]) => typeof builder - in: (...args: unknown[]) => typeof builder - is: (...args: unknown[]) => typeof builder - order: (...args: unknown[]) => typeof builder - limit: (...args: unknown[]) => typeof builder - then: ( - onFulfilled: (value: ChainResponse) => unknown, - onRejected?: (reason: unknown) => unknown, - ) => Promise - } = { - select: () => builder, - eq: () => builder, - in: () => builder, - is: () => builder, - order: () => builder, - limit: () => builder, - then: (onFulfilled, onRejected) => - Promise.resolve(next()).then(onFulfilled, onRejected), - } - return builder - } - - return { - from: (table: string) => { - fromSpy(table) - return chain(table) - }, - fromSpy, - } -} - -describe('getViewerTagAffinity', () => { - // The affinity cache lives at module level — clear it between tests so - // each case starts from a known empty state. Without this, the second - // call for the same viewerId would short-circuit on the cached entry - // and the test stubs would never be invoked. - beforeEach(() => { - _clearAffinityCacheForTests() - }) - it('returns an empty set when the viewer has no engagement signals', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = makeStub({}) as any - const result = await getViewerTagAffinity(db, 'viewer-1', { now: NOW }) - expect(result).toBeInstanceOf(Set) - expect(result.size).toBe(0) - }) - - it('weighs recent events higher than older events for the same tag', async () => { - // Tag "old-favorite" was liked 60 days ago. Tag "new-favorite" was liked - // 1 day ago. The viewer should end up biased toward the recent one. - const db = makeStub({ - likes: { - data: [ - { - created_at: daysAgo(1), - posts: { - post_tags: [ - { tag_slug: 'new-favorite', tags: { slug: 'new-favorite', is_approved: true } }, - ], - }, - }, - { - created_at: daysAgo(60), - posts: { - post_tags: [ - { tag_slug: 'old-favorite', tags: { slug: 'old-favorite', is_approved: true } }, - ], - }, - }, - ], - error: null, - }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - posts: { data: [], error: null }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - - const result = await getViewerTagAffinity(db, 'viewer-1', { - now: NOW, - limit: 1, - }) - expect(result.size).toBe(1) - expect(result.has('new-favorite')).toBe(true) - expect(result.has('old-favorite')).toBe(false) - }) - - it('caps the result to `limit` tag slugs (default 8)', async () => { - // Twelve distinct tags, all liked today — well above the default limit. - const likes = Array.from({ length: 12 }, (_, i) => ({ - created_at: daysAgo(0), - posts: { - post_tags: [ - { - tag_slug: `tag-${i}`, - tags: { slug: `tag-${i}`, is_approved: true }, - }, - ], - }, - })) - - const db = makeStub({ - likes: { data: likes, error: null }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - posts: { data: [], error: null }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - - const def = await getViewerTagAffinity(db, 'viewer-1', { now: NOW }) - expect(def.size).toBe(8) - - const callsAfterFirst = db.fromSpy.mock.calls.length - const three = await getViewerTagAffinity(db, 'viewer-1', { - now: NOW, - limit: 3, - }) - expect(three.size).toBe(3) - expect(db.fromSpy.mock.calls.length).toBe(callsAfterFirst) // cache hit, no new DB calls - }) - - it('filters out tags whose tags.is_approved is false', async () => { - const db = makeStub({ - likes: { - data: [ - { - created_at: daysAgo(0), - posts: { - post_tags: [ - // approved → included - { tag_slug: 'approved-tag', tags: { slug: 'approved-tag', is_approved: true } }, - // unapproved → dropped, even though it's a very recent like - { - tag_slug: 'unapproved-tag', - tags: { slug: 'unapproved-tag', is_approved: false }, - }, - ], - }, - }, - ], - error: null, - }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - posts: { data: [], error: null }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - - const result = await getViewerTagAffinity(db, 'viewer-1', { now: NOW }) - expect(result.has('approved-tag')).toBe(true) - expect(result.has('unapproved-tag')).toBe(false) - }) - - it('aggregates weights across likes, bookmarks, and follows', async () => { - // The viewer has three signals on three different tags: - // - 'liked' tag came from a like 1 day ago - // - 'bookmarked' tag came from a bookmark 1 day ago - // - 'followed-author' tag came from a post by someone they follow, - // published 1 day ago - // All three sources should be represented in the resulting set. - const db = makeStub({ - likes: { - data: [ - { - created_at: daysAgo(1), - posts: { - post_tags: [{ tag_slug: 'liked', tags: { slug: 'liked', is_approved: true } }], - }, - }, - ], - error: null, - }, - bookmarks: { - data: [ - { - created_at: daysAgo(1), - posts: { - post_tags: [ - { tag_slug: 'bookmarked', tags: { slug: 'bookmarked', is_approved: true } }, - ], - }, - }, - ], - error: null, - }, - follows: { - data: [{ followed_id: 'author-a' }, { followed_id: 'author-b' }], - error: null, - }, - posts: { - data: [ - { - published_at: daysAgo(1), - post_tags: [ - { - tag_slug: 'followed-author', - tags: { slug: 'followed-author', is_approved: true }, - }, - ], - }, - ], - error: null, - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - - const result = await getViewerTagAffinity(db, 'viewer-1', { now: NOW }) - expect(result.has('liked')).toBe(true) - expect(result.has('bookmarked')).toBe(true) - expect(result.has('followed-author')).toBe(true) - }) - - it('returns an empty set when the viewer has no follows (no posts query)', async () => { - // Sanity check: when follows returns [], we don't blow up trying to - // build a posts-by-author query with an empty IN list. - const db = makeStub({ - likes: { data: [], error: null }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - - const result = await getViewerTagAffinity(db, 'viewer-1', { now: NOW }) - expect(result.size).toBe(0) - }) - - it('is robust to error responses (treats source as empty)', async () => { - const db = makeStub({ - likes: { data: null, error: { message: 'boom' } }, - bookmarks: { data: null, error: { message: 'boom' } }, - follows: { data: null, error: { message: 'boom' } }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - const result = await getViewerTagAffinity(db, 'viewer-1', { now: NOW }) - expect(result.size).toBe(0) - }) - - it('issues the three engagement queries in parallel (no inter-dependence)', async () => { - // A naive sequential implementation would hit the tables one at a - // time. The Promise.all-based implementation hits them in the same - // tick. We can't directly assert wall-clock ordering inside a - // single-threaded event loop, but we CAN assert that all three - // independent tables are queried even before the follows result is - // available (i.e. follows step doesn't gate likes/bookmarks). We do - // this indirectly: by the time the function resolves, .from() must - // have been called with exactly {likes, bookmarks, follows} for the - // no-follows case (NO 'posts' call). - const stub = makeStub({ - likes: { data: [], error: null }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await getViewerTagAffinity(stub as any, 'viewer-parallel', { now: NOW }) - - const tablesHit = stub.fromSpy.mock.calls.map((c) => c[0]) - expect(tablesHit).toContain('likes') - expect(tablesHit).toContain('bookmarks') - expect(tablesHit).toContain('follows') - expect(tablesHit).not.toContain('posts') - }) - - it('still runs the author-posts follow-up after follows resolves', async () => { - const stub = makeStub({ - likes: { data: [], error: null }, - bookmarks: { data: [], error: null }, - follows: { - data: [{ followed_id: 'author-x' }], - error: null, - }, - posts: { - data: [ - { - published_at: daysAgo(1), - post_tags: [ - { - tag_slug: 'from-author', - tags: { slug: 'from-author', is_approved: true }, - }, - ], - }, - ], - error: null, - }, - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await getViewerTagAffinity(stub as any, 'viewer-follows', { - now: NOW, - }) - expect(result.has('from-author')).toBe(true) - const tablesHit = stub.fromSpy.mock.calls.map((c) => c[0]) - expect(tablesHit).toContain('follows') - expect(tablesHit).toContain('posts') - }) - - describe('cache', () => { - it('does not re-hit the DB on a second call within TTL', async () => { - const stub = makeStub({ - likes: { - data: [ - { - created_at: daysAgo(1), - posts: { - post_tags: [ - { tag_slug: 'cached-tag', tags: { slug: 'cached-tag', is_approved: true } }, - ], - }, - }, - ], - error: null, - }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const first = await getViewerTagAffinity(stub as any, 'viewer-cache', { - now: NOW, - }) - expect(first.has('cached-tag')).toBe(true) - const callsAfterFirst = stub.fromSpy.mock.calls.length - expect(callsAfterFirst).toBeGreaterThan(0) - - // Second call within TTL — should be a pure cache hit, zero new DB calls. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const second = await getViewerTagAffinity(stub as any, 'viewer-cache', { - now: NOW, - }) - expect(second.has('cached-tag')).toBe(true) - expect(stub.fromSpy.mock.calls.length).toBe(callsAfterFirst) - }) - - it('refetches once TTL expires', async () => { - const stub = makeStub({ - likes: { - data: [ - { - created_at: daysAgo(1), - posts: { - post_tags: [ - { tag_slug: 'ttl-tag', tags: { slug: 'ttl-tag', is_approved: true } }, - ], - }, - }, - ], - error: null, - }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await getViewerTagAffinity(stub as any, 'viewer-ttl', { now: NOW }) - const baseline = stub.fromSpy.mock.calls.length - - // Just before expiry: still a hit. - const justBeforeExpiry = new Date(NOW.getTime() + 4 * 60 * 1000) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await getViewerTagAffinity(stub as any, 'viewer-ttl', { - now: justBeforeExpiry, - }) - expect(stub.fromSpy.mock.calls.length).toBe(baseline) - - // Past expiry (5min + 1s): the cache entry is gone, expect a refetch. - const afterExpiry = new Date(NOW.getTime() + 5 * 60 * 1000 + 1000) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await getViewerTagAffinity(stub as any, 'viewer-ttl', { - now: afterExpiry, - }) - expect(stub.fromSpy.mock.calls.length).toBeGreaterThan(baseline) - }) - - it('keeps separate cache entries per viewer', async () => { - const stub = makeStub({ - likes: { data: [], error: null }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await getViewerTagAffinity(stub as any, 'viewer-a', { now: NOW }) - const afterA = stub.fromSpy.mock.calls.length - - // Different viewer — must hit the DB even though A is still cached. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await getViewerTagAffinity(stub as any, 'viewer-b', { now: NOW }) - expect(stub.fromSpy.mock.calls.length).toBeGreaterThan(afterA) - }) - - it('_clearAffinityCacheForTests() forces a refetch on next call', async () => { - const stub = makeStub({ - likes: { data: [], error: null }, - bookmarks: { data: [], error: null }, - follows: { data: [], error: null }, - }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await getViewerTagAffinity(stub as any, 'viewer-clear', { now: NOW }) - const baseline = stub.fromSpy.mock.calls.length - - // Without clear, second call within TTL would hit the cache. - _clearAffinityCacheForTests() - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await getViewerTagAffinity(stub as any, 'viewer-clear', { now: NOW }) - expect(stub.fromSpy.mock.calls.length).toBeGreaterThan(baseline) - }) - }) -}) diff --git a/tests/unit/feed/rerank.test.ts b/tests/unit/feed/rerank.test.ts deleted file mode 100644 index cfceb14..0000000 --- a/tests/unit/feed/rerank.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { rerankWithAffinity } from '@/lib/feed/rerank' -import type { RerankRow } from '@/lib/feed/rerank' - -// Pin "now" so tests don't depend on wall-clock time. -const NOW = new Date('2026-05-30T12:00:00.000Z') - -function hoursAgo(h: number): string { - return new Date(NOW.getTime() - h * 3_600_000).toISOString() -} - -function makeRow(overrides: Partial): RerankRow { - return { - id: 'row-base', - author_id: 'author-1', - type: 'post', - slug: 'slug', - title: 'title', - summary: 'summary', - cover_image_url: null, - published_at: hoursAgo(1), - like_count: 0, - bookmark_count: 0, - comment_count: 0, - tag_slugs: [], - ...overrides, - } -} - -describe('rerankWithAffinity', () => { - it('returns an empty array when given no rows', () => { - const out = rerankWithAffinity([], new Set(['memory']), { now: NOW }) - expect(out).toEqual([]) - }) - - it('boosts a post whose tags overlap the viewer affinity above an identical one without overlap', () => { - const withMatch = makeRow({ - id: 'with-match', - tag_slugs: ['memory', 'evals'], - like_count: 10, - bookmark_count: 1, - published_at: hoursAgo(3), - }) - const withoutMatch = makeRow({ - id: 'without-match', - tag_slugs: ['orchestration'], - like_count: 10, - bookmark_count: 1, - published_at: hoursAgo(3), - }) - - const ranked = rerankWithAffinity([withoutMatch, withMatch], new Set(['memory']), { - now: NOW, - }) - - expect(ranked.map((r) => r.id)).toEqual(['with-match', 'without-match']) - }) - - it('a fresh post with modest engagement outranks an old post with many likes', () => { - const fresh = makeRow({ - id: 'fresh', - tag_slugs: [], - like_count: 10, - bookmark_count: 0, - published_at: hoursAgo(1), - }) - const ancient = makeRow({ - id: 'ancient', - tag_slugs: [], - like_count: 100, - bookmark_count: 0, - // 14 days old - published_at: hoursAgo(24 * 14), - }) - - const ranked = rerankWithAffinity([ancient, fresh], new Set(), { now: NOW }) - expect(ranked[0].id).toBe('fresh') - }) - - it('breaks ties on id desc when two rows have equal heat scores', () => { - // Two rows with identical engagement, age, and (lack of) overlap → - // computeHeatScore returns the same value. The tiebreaker is id desc. - const a = makeRow({ - id: 'aaa', - tag_slugs: [], - like_count: 5, - bookmark_count: 0, - published_at: hoursAgo(2), - }) - const b = makeRow({ - id: 'bbb', - tag_slugs: [], - like_count: 5, - bookmark_count: 0, - published_at: hoursAgo(2), - }) - - const ranked = rerankWithAffinity([a, b], new Set(), { now: NOW }) - expect(ranked.map((r) => r.id)).toEqual(['bbb', 'aaa']) - }) - - it('respects the `limit` option (default 30)', () => { - const rows: RerankRow[] = Array.from({ length: 40 }, (_, i) => - makeRow({ - id: `id-${String(i).padStart(2, '0')}`, - like_count: 40 - i, - published_at: hoursAgo(1), - }), - ) - const defaulted = rerankWithAffinity(rows, new Set(), { now: NOW }) - expect(defaulted.length).toBe(30) - - const limited = rerankWithAffinity(rows, new Set(), { now: NOW, limit: 5 }) - expect(limited.length).toBe(5) - }) - - it('does not mutate the input rows', () => { - const rows = [ - makeRow({ id: 'one', tag_slugs: ['memory'], like_count: 1 }), - makeRow({ id: 'two', tag_slugs: [], like_count: 1 }), - ] - const snapshot = JSON.stringify(rows) - rerankWithAffinity(rows, new Set(['memory']), { now: NOW }) - expect(JSON.stringify(rows)).toBe(snapshot) - }) - - it('treats an empty affinity set as zero overlap for every row', () => { - // With no affinity, ranking should be driven purely by engagement / - // recency. Higher like_count wins at the same age. - const high = makeRow({ - id: 'high', - tag_slugs: ['memory'], - like_count: 50, - published_at: hoursAgo(2), - }) - const low = makeRow({ - id: 'low', - tag_slugs: ['memory'], - like_count: 1, - published_at: hoursAgo(2), - }) - const ranked = rerankWithAffinity([low, high], new Set(), { now: NOW }) - expect(ranked.map((r) => r.id)).toEqual(['high', 'low']) - }) -}) diff --git a/tests/unit/feed/top-by-type.test.ts b/tests/unit/feed/top-by-type.test.ts index f018e4b..ceaa13b 100644 --- a/tests/unit/feed/top-by-type.test.ts +++ b/tests/unit/feed/top-by-type.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi } from 'vitest' import { getTopByType } from '@/lib/feed/top-by-type' -import { computeHeatScore } from '@/lib/heat' // --------------------------------------------------------------------------- // Fixture helpers @@ -19,8 +18,6 @@ interface RowOpts { type?: string org_id?: string | null published_at?: string - like_count?: number - bookmark_count?: number authorUsername?: string authorDisplayName?: string | null orgSlug?: string | null @@ -34,8 +31,6 @@ function makeRawRow(opts: RowOpts = {}) { type = 'playbook', org_id = null, published_at = hoursAgo(2), - like_count = 5, - bookmark_count = 0, authorUsername = 'alice', authorDisplayName = 'Alice', orgSlug = null, @@ -47,20 +42,20 @@ function makeRawRow(opts: RowOpts = {}) { type, org_id, published_at, - like_count, - bookmark_count, author: { username: authorUsername, display_name: authorDisplayName }, orgs: orgSlug !== null ? { slug: orgSlug } : null, } } /** - * Build a fake Supabase db that returns the given rows. + * Build a fake Supabase db that returns the given rows (already in the + * order the real query would return them: `published_at` descending). * Captures the .eq and .gte arguments for assertion. */ function buildDb(rows: ReturnType[], opts: { error?: boolean } = {}) { const eqArgs: Array<[string, unknown]> = [] const gteArgs: Array<[string, unknown]> = [] + const orderArgs: Array<[string, unknown]> = [] const chain: Record> & { then?: (r: (v: { data: unknown; error: unknown }) => void) => void @@ -74,7 +69,10 @@ function buildDb(rows: ReturnType[], opts: { error?: boolean return chain }), is: vi.fn(() => chain), - order: vi.fn(() => chain), + order: vi.fn((col: string, opts: unknown) => { + orderArgs.push([col, opts]) + return chain + }), limit: vi.fn(() => chain), } @@ -92,6 +90,7 @@ function buildDb(rows: ReturnType[], opts: { error?: boolean })), getEqArgs: () => eqArgs, getGteArgs: () => gteArgs, + getOrderArgs: () => orderArgs, } return db @@ -118,49 +117,39 @@ describe('getTopByType', () => { it('respects the limit', async () => { const rows = [ - makeRawRow({ id: '1', like_count: 10, published_at: hoursAgo(1) }), - makeRawRow({ id: '2', like_count: 8, published_at: hoursAgo(1) }), - makeRawRow({ id: '3', like_count: 6, published_at: hoursAgo(1) }), - makeRawRow({ id: '4', like_count: 4, published_at: hoursAgo(1) }), + makeRawRow({ id: '1', published_at: hoursAgo(1) }), + makeRawRow({ id: '2', published_at: hoursAgo(2) }), + makeRawRow({ id: '3', published_at: hoursAgo(3) }), + makeRawRow({ id: '4', published_at: hoursAgo(4) }), ] const db = buildDb(rows) const result = await getTopByType(db as never, 'playbook', 7, 2) expect(result).toHaveLength(2) }) - it('sorts by computeHeatScore: older high-engagement beats recent low-engagement', async () => { - // "recent-low": published 1h ago, like_count=2 → lower score - // "old-high": published 48h ago, like_count=100 → higher score - const recentLow = makeRawRow({ - id: 'recent-low', - like_count: 2, - bookmark_count: 0, + it('preserves the query order (published_at descending): recency decides rank', async () => { + // The DB query orders by published_at descending; a recently published + // post must outrank an older one, because ranking is pure recency. + const recent = makeRawRow({ + id: 'recent', published_at: hoursAgo(1), }) - const oldHigh = makeRawRow({ - id: 'old-high', - like_count: 100, - bookmark_count: 20, + const older = makeRawRow({ + id: 'older', published_at: hoursAgo(48), }) - // Verify expected order using the REAL computeHeatScore. - const scoreRecentLow = computeHeatScore( - { published_at: recentLow.published_at, like_count: 2, bookmark_count: 0, tag_affinity: 0 }, - NOW, - ) - const scoreOldHigh = computeHeatScore( - { published_at: oldHigh.published_at, like_count: 100, bookmark_count: 20, tag_affinity: 0 }, - NOW, - ) - expect(scoreOldHigh).toBeGreaterThan(scoreRecentLow) - - const db = buildDb([recentLow, oldHigh]) + // Rows arrive already ordered by published_at desc, as the real query + // would return them: recent first. + const db = buildDb([recent, older]) const result = await getTopByType(db as never, 'playbook', 7, 3) - // Highest heat score should come first. - expect(result[0].id).toBe('old-high') - expect(result[1].id).toBe('recent-low') + expect(result[0].id).toBe('recent') + expect(result[1].id).toBe('older') + + // Assert the query calls .order with the correct column and direction. + const orderArgs = db.getOrderArgs() + expect(orderArgs.some(([col, opts]) => col === 'published_at' && opts && typeof opts === 'object' && 'ascending' in opts && opts.ascending === false)).toBe(true) }) it('returns [] on DB error', async () => { @@ -206,7 +195,7 @@ describe('getTopByType', () => { expect(result[0].leading_segment).toBe('bob') }) - it('filters published_at with the correct window cutoff (mirrors trending-tags pattern)', async () => { + it('filters published_at with the correct window cutoff', async () => { const fakeNow = new Date('2026-06-01T12:00:00.000Z') const expectedSince = new Date(fakeNow.getTime() - 7 * 86_400_000).toISOString() diff --git a/tests/unit/feed/trending-tags.test.ts b/tests/unit/feed/trending-tags.test.ts deleted file mode 100644 index 3848224..0000000 --- a/tests/unit/feed/trending-tags.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { getTrendingTags } from '@/lib/feed/trending-tags' - -// --------------------------------------------------------------------------- -// Mock DB builder helpers -// --------------------------------------------------------------------------- - -/** - * Build a post_tags row fixture for the mock. Simulates the joined shape - * returned by PostgREST when .select(...tags!inner..., posts!inner...) is used. - */ -function makeRow(tagSlug: string, tagName: string, publishedAt: string) { - return { - tag_slug: tagSlug, - tags: { slug: tagSlug, name: tagName, is_approved: true }, - posts: { published_at: publishedAt, deleted_at: null }, - } -} - -/** - * Build a chainable Supabase stub that returns the given rows (or an error). - */ -function buildDb( - rows: ReturnType[], - opts: { error?: boolean } = {}, -) { - let capturedGte: string | null = null - - const chain = { - gte: vi.fn((col: string, val: string) => { - if (col === 'posts.published_at') capturedGte = val - return chain - }), - is: vi.fn(() => chain), - eq: vi.fn(() => chain), - then: undefined as unknown, - } - - // Make it "thenable" so await works — resolves like a Supabase response. - const thenable = { - ...chain, - then( - resolve: (v: { data: unknown; error: unknown }) => void, - ) { - resolve( - opts.error - ? { data: null, error: new Error('db error') } - : { data: rows, error: null }, - ) - }, - getCapturedGte: () => capturedGte, - } - - // Wire the chain to return the thenable at every step. - chain.gte.mockReturnValue(thenable) - chain.is.mockReturnValue(thenable) - chain.eq.mockReturnValue(thenable) - - let selectChain = thenable - - const db = { - from: vi.fn(() => ({ - select: vi.fn(() => { - selectChain = thenable - return selectChain - }), - })), - _getGte: () => capturedGte, - } - - return db -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('getTrendingTags', () => { - it('counts occurrences per tag_slug and sorts by count descending', async () => { - const now = new Date('2026-06-01T00:00:00Z') - const recent = new Date(now.getTime() - 3 * 86_400_000).toISOString() - const rows = [ - makeRow('memory', 'Memory', recent), - makeRow('evals', 'Evals', recent), - makeRow('memory', 'Memory', recent), - makeRow('memory', 'Memory', recent), - makeRow('evals', 'Evals', recent), - ] - - const db = buildDb(rows) - const result = await getTrendingTags(db as never, 7, 5) - - expect(result[0].slug).toBe('memory') - expect(result[0].count).toBe(3) - expect(result[1].slug).toBe('evals') - expect(result[1].count).toBe(2) - }) - - it('returns [] on DB error', async () => { - const db = buildDb([], { error: true }) - const result = await getTrendingTags(db as never, 7, 5) - expect(result).toEqual([]) - }) - - it('respects the limit', async () => { - const recent = new Date(Date.now() - 86_400_000).toISOString() - const rows = [ - makeRow('a', 'A', recent), - makeRow('b', 'B', recent), - makeRow('c', 'C', recent), - makeRow('d', 'D', recent), - ] - const db = buildDb(rows) - const result = await getTrendingTags(db as never, 7, 2) - expect(result).toHaveLength(2) - }) - - it('filters posts.deleted_at IS NULL (soft-delete guard)', async () => { - const isArgs: Array<[string, unknown]> = [] - const dbSpy = { - from: vi.fn(() => ({ - select: vi.fn(() => { - const inner = { - gte: vi.fn().mockReturnThis(), - is: vi.fn((col: string, val: unknown) => { - isArgs.push([col, val]) - return inner - }), - eq: vi.fn().mockReturnThis(), - then(resolve: (v: { data: unknown; error: unknown }) => void) { - resolve({ data: [], error: null }) - }, - } - return inner - }), - })), - } - - await getTrendingTags(dbSpy as never, 7, 5) - - expect(isArgs.some(([col, val]) => col === 'posts.deleted_at' && val === null)).toBe(true) - }) - - it('passes the window cutoff as the .gte argument', async () => { - const fakeNow = new Date('2026-06-01T12:00:00.000Z') - const expectedSince = new Date( - fakeNow.getTime() - 7 * 86_400_000, - ).toISOString() - - const callSpy = vi.fn() - const dbSpy = { - from: vi.fn(() => ({ - select: vi.fn(() => { - const inner = { - gte: callSpy.mockReturnThis(), - is: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - then(resolve: (v: { data: unknown; error: unknown }) => void) { - resolve({ data: [], error: null }) - }, - } - return inner - }), - })), - } - - vi.useFakeTimers() - vi.setSystemTime(fakeNow) - await getTrendingTags(dbSpy as never, 7, 5) - vi.useRealTimers() - - expect(callSpy).toHaveBeenCalledWith('posts.published_at', expectedSince) - }) -}) diff --git a/tests/unit/heat.test.ts b/tests/unit/heat.test.ts deleted file mode 100644 index 4d27cc1..0000000 --- a/tests/unit/heat.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { computeHeatScore } from '../../lib/heat' - -// All tests pin `now` so they are deterministic against `published_at`. -const NOW = new Date('2026-05-30T12:00:00.000Z') - -function hoursAgo(h: number): string { - return new Date(NOW.getTime() - h * 3_600_000).toISOString() -} - -describe('computeHeatScore', () => { - it('returns 0 when there is no engagement, regardless of age', () => { - const fresh = computeHeatScore( - { - published_at: hoursAgo(0), - like_count: 0, - bookmark_count: 0, - tag_affinity: 0, - }, - NOW, - ) - const old = computeHeatScore( - { - published_at: hoursAgo(24 * 30), - like_count: 0, - bookmark_count: 0, - tag_affinity: 0, - }, - NOW, - ) - expect(fresh).toBe(0) - expect(old).toBe(0) - }) - - it('decays meaningfully as a post ages with the same like count', () => { - const fresh = computeHeatScore( - { - published_at: hoursAgo(1), - like_count: 100, - bookmark_count: 0, - tag_affinity: 0, - }, - NOW, - ) - const ancient = computeHeatScore( - { - published_at: hoursAgo(24 * 30), // 30 days old - like_count: 100, - bookmark_count: 0, - tag_affinity: 0, - }, - NOW, - ) - expect(ancient).toBeLessThan(fresh) - // The decay should be significant — order-of-magnitude small at 30 days. - expect(ancient).toBeLessThan(fresh / 10) - expect(ancient).toBeGreaterThan(0) - }) - - it('returns a positive finite number for a fresh post with high likes', () => { - const score = computeHeatScore( - { - published_at: hoursAgo(0.5), - like_count: 50, - bookmark_count: 5, - tag_affinity: 0, - }, - NOW, - ) - expect(Number.isFinite(score)).toBe(true) - expect(score).toBeGreaterThan(0) - }) - - it('weights bookmarks 2x likes in the numerator', () => { - // 10 bookmarks (numerator 20) vs 10 likes (numerator 10), same age, - // same denominator => bookmarks score = 2 * likes score. - const published = hoursAgo(3) - const withLikes = computeHeatScore( - { - published_at: published, - like_count: 10, - bookmark_count: 0, - tag_affinity: 0, - }, - NOW, - ) - const withBookmarks = computeHeatScore( - { - published_at: published, - like_count: 0, - bookmark_count: 10, - tag_affinity: 0, - }, - NOW, - ) - expect(withBookmarks).toBeCloseTo(2 * withLikes, 10) - }) - - it('adds the +5 tag_affinity boost when tag_affinity > 0', () => { - const base = { - published_at: hoursAgo(2), - like_count: 3, - bookmark_count: 1, - } - const without = computeHeatScore({ ...base, tag_affinity: 0 }, NOW) - const withAffinity = computeHeatScore({ ...base, tag_affinity: 1 }, NOW) - - // Boost should fire and produce a strictly higher score. - expect(withAffinity).toBeGreaterThan(without) - - // Verify the boost magnitude is exactly +5 in the numerator: the - // denominator is shared, so (with - without) * denom should equal 5. - const hours = 2 - const denom = Math.pow(hours + 2, 1.5) - expect((withAffinity - without) * denom).toBeCloseTo(5, 10) - }) - - it('treats any tag_affinity > 0 as the same flat boost (not scaled)', () => { - const base = { - published_at: hoursAgo(2), - like_count: 0, - bookmark_count: 0, - } - const one = computeHeatScore({ ...base, tag_affinity: 1 }, NOW) - const many = computeHeatScore({ ...base, tag_affinity: 99 }, NOW) - expect(many).toBe(one) - }) - - it('clamps future-dated posts to the freshest bucket (0 hours)', () => { - const engagement = { - like_count: 10, - bookmark_count: 2, - tag_affinity: 0, - } - const fresh = computeHeatScore( - { ...engagement, published_at: hoursAgo(0) }, - NOW, - ) - // published_at is 2 days in the future relative to NOW. - const future = computeHeatScore( - { ...engagement, published_at: hoursAgo(-48) }, - NOW, - ) - expect(Number.isFinite(future)).toBe(true) - expect(future).toBe(fresh) - }) - - it('treats a malformed published_at as freshly published (no NaN/Infinity)', () => { - const engagement = { - like_count: 10, - bookmark_count: 2, - tag_affinity: 0, - } - const fresh = computeHeatScore( - { ...engagement, published_at: hoursAgo(0) }, - NOW, - ) - const malformed = computeHeatScore( - { ...engagement, published_at: 'not-a-date' }, - NOW, - ) - expect(Number.isFinite(malformed)).toBe(true) - expect(malformed).toBe(fresh) - }) - - it('is pure — same inputs return the same output', () => { - const input = { - published_at: hoursAgo(7), - like_count: 12, - bookmark_count: 3, - tag_affinity: 1, - } - const a = computeHeatScore(input, NOW) - const b = computeHeatScore(input, NOW) - const c = computeHeatScore({ ...input }, new Date(NOW.getTime())) - expect(a).toBe(b) - expect(a).toBe(c) - }) -}) diff --git a/tests/unit/migration-0004.test.ts b/tests/unit/migration-0004.test.ts deleted file mode 100644 index 1c3a99e..0000000 --- a/tests/unit/migration-0004.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' - -let migration: string - -beforeAll(() => { - migration = readFileSync( - resolve(__dirname, '../../supabase/migrations/0004_view_count_rpc.sql'), - 'utf8', - ) -}) - -describe('0004_view_count_rpc.sql shape', () => { - it('creates the increment_post_view_count function with CREATE OR REPLACE', () => { - expect(migration).toMatch( - /CREATE OR REPLACE FUNCTION public\.increment_post_view_count\(p_id uuid\)/, - ) - }) - - it('function is declared SECURITY DEFINER', () => { - expect(migration).toMatch(/SECURITY DEFINER/) - }) - - it('function sets search_path = public, pg_temp (defense pattern)', () => { - expect(migration).toMatch(/SET search_path = public, pg_temp/) - }) - - it('increments view_count atomically in the UPDATE body', () => { - expect(migration).toMatch(/view_count\s*=\s*view_count\s*\+\s*1/) - }) - - it('guards against soft-deleted posts (deleted_at IS NULL)', () => { - expect(migration).toMatch(/deleted_at IS NULL/) - }) - - it('grants execute to anon, authenticated, and service_role', () => { - expect(migration).toMatch(/GRANT EXECUTE ON FUNCTION public\.increment_post_view_count\(uuid\)/) - expect(migration).toMatch(/anon/) - expect(migration).toMatch(/authenticated/) - expect(migration).toMatch(/service_role/) - }) -}) diff --git a/tests/unit/migration-0007.test.ts b/tests/unit/migration-0007.test.ts deleted file mode 100644 index 06862ab..0000000 --- a/tests/unit/migration-0007.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' - -let migration: string - -beforeAll(() => { - migration = readFileSync( - resolve(__dirname, '../../supabase/migrations/0007_comments_count_and_depth.sql'), - 'utf8', - ) -}) - -describe('0007_comments_count_and_depth.sql shape', () => { - it('adds posts.comment_count column with IF NOT EXISTS', () => { - expect(migration).toMatch( - /ALTER TABLE public\.posts[\s\S]*ADD COLUMN IF NOT EXISTS comment_count integer NOT NULL DEFAULT 0/, - ) - }) - - it('defines handle_comment_count_change trigger function', () => { - expect(migration).toMatch( - /CREATE OR REPLACE FUNCTION public\.handle_comment_count_change\(\)/, - ) - }) - - it('trigger function is SECURITY DEFINER', () => { - expect(migration).toMatch(/handle_comment_count_change[\s\S]*SECURITY DEFINER/) - }) - - it('trigger function locks search_path to public, pg_temp', () => { - expect(migration).toMatch( - /handle_comment_count_change[\s\S]*SET search_path = public, pg_temp/, - ) - }) - - it('increments comment_count on INSERT', () => { - expect(migration).toMatch(/comment_count\s*=\s*comment_count\s*\+\s*1/) - }) - - it('decrements comment_count on DELETE / soft-delete', () => { - expect(migration).toMatch(/comment_count\s*=\s*comment_count\s*-\s*1/) - }) - - it('handles UPDATE of deleted_at NULL -> NOT NULL (soft delete)', () => { - expect(migration).toMatch( - /OLD\.deleted_at IS NULL AND NEW\.deleted_at IS NOT NULL/, - ) - }) - - it('handles UPDATE of deleted_at NOT NULL -> NULL (restore)', () => { - expect(migration).toMatch( - /OLD\.deleted_at IS NOT NULL AND NEW\.deleted_at IS NULL/, - ) - }) - - it('creates AFTER INSERT/UPDATE OF deleted_at/DELETE trigger named comments_count_trigger', () => { - expect(migration).toMatch( - /CREATE TRIGGER comments_count_trigger[\s\S]*AFTER INSERT OR UPDATE OF deleted_at OR DELETE ON public\.comments/, - ) - }) - - it('trigger calls handle_comment_count_change', () => { - expect(migration).toMatch( - /EXECUTE FUNCTION public\.handle_comment_count_change\(\)/, - ) - }) - - it('defines comment_depth_for_parent(uuid) RPC', () => { - expect(migration).toMatch( - /CREATE OR REPLACE FUNCTION public\.comment_depth_for_parent\(p_parent uuid\)/, - ) - }) - - it('depth RPC uses a recursive CTE walking parent_comment_id', () => { - expect(migration).toMatch(/WITH RECURSIVE[\s\S]*parent_comment_id/) - }) - - it('depth RPC has an explicit cycle guard (bounded termination)', () => { - // The FK on comments.parent_comment_id does not prevent id = - // parent_comment_id self-cycles or multi-row cycles, which would loop - // the recursive CTE forever. Require Postgres `CYCLE id` syntax. - expect(migration).toMatch(/CYCLE\s+id\s+SET\s+is_cycle\s+USING\s+path/) - expect(migration).toMatch(/WHERE\s+NOT\s+is_cycle/) - }) - - it('depth RPC returns NULL (not 0) when the parent does not exist', () => { - // NULLIF(count(*)::integer, 0) lets the application layer distinguish - // "parent missing" from "parent is a root with depth 1". - expect(migration).toMatch(/NULLIF\(count\(\*\)::integer,\s*0\)/) - }) - - it('depth RPC is SECURITY DEFINER with locked search_path', () => { - expect(migration).toMatch( - /comment_depth_for_parent[\s\S]*SECURITY DEFINER[\s\S]*SET search_path = public, pg_temp/, - ) - }) - - it('grants execute on depth RPC to anon, authenticated, service_role', () => { - expect(migration).toMatch( - /GRANT EXECUTE ON FUNCTION public\.comment_depth_for_parent\(uuid\)[\s\S]*anon[\s\S]*authenticated[\s\S]*service_role/, - ) - }) - - it('backfills comment_count from existing comments', () => { - expect(migration).toMatch( - /UPDATE public\.posts[\s\S]*SET comment_count[\s\S]*FROM public\.comments[\s\S]*deleted_at IS NULL/, - ) - }) -}) diff --git a/tests/unit/migration-0008.test.ts b/tests/unit/migration-0008.test.ts deleted file mode 100644 index b765eee..0000000 --- a/tests/unit/migration-0008.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' - -let migration: string - -beforeAll(() => { - migration = readFileSync( - resolve(__dirname, '../../supabase/migrations/0008_engagement_counts.sql'), - 'utf8', - ) -}) - -describe('0008_engagement_counts.sql shape', () => { - // ------------------------------------------------------------------------- - // Columns - // ------------------------------------------------------------------------- - it('adds posts.like_count column with IF NOT EXISTS', () => { - expect(migration).toMatch( - /ALTER TABLE public\.posts[\s\S]*ADD COLUMN IF NOT EXISTS like_count integer NOT NULL DEFAULT 0/, - ) - }) - - it('adds posts.bookmark_count column with IF NOT EXISTS', () => { - expect(migration).toMatch( - /ALTER TABLE public\.posts[\s\S]*ADD COLUMN IF NOT EXISTS bookmark_count integer NOT NULL DEFAULT 0/, - ) - }) - - it('adds users.follower_count column with IF NOT EXISTS', () => { - expect(migration).toMatch( - /ALTER TABLE public\.users[\s\S]*ADD COLUMN IF NOT EXISTS follower_count integer NOT NULL DEFAULT 0/, - ) - }) - - it('adds users.following_count column with IF NOT EXISTS', () => { - expect(migration).toMatch( - /ALTER TABLE public\.users[\s\S]*ADD COLUMN IF NOT EXISTS following_count integer NOT NULL DEFAULT 0/, - ) - }) - - // ------------------------------------------------------------------------- - // handle_like_count_change - // ------------------------------------------------------------------------- - it('defines handle_like_count_change trigger function', () => { - expect(migration).toMatch( - /CREATE OR REPLACE FUNCTION public\.handle_like_count_change\(\)/, - ) - }) - - it('handle_like_count_change is SECURITY DEFINER with locked search_path', () => { - expect(migration).toMatch( - /handle_like_count_change[\s\S]*SECURITY DEFINER[\s\S]*SET search_path = public, pg_temp/, - ) - }) - - it('handle_like_count_change increments on INSERT and decrements on DELETE', () => { - expect(migration).toMatch( - /handle_like_count_change[\s\S]*TG_OP = 'INSERT'[\s\S]*like_count\s*=\s*like_count\s*\+\s*1[\s\S]*WHERE id = NEW\.post_id/, - ) - expect(migration).toMatch( - /handle_like_count_change[\s\S]*TG_OP = 'DELETE'[\s\S]*like_count\s*=\s*like_count\s*-\s*1[\s\S]*WHERE id = OLD\.post_id/, - ) - }) - - it('creates AFTER INSERT OR DELETE trigger likes_count_trigger on public.likes', () => { - expect(migration).toMatch( - /CREATE TRIGGER likes_count_trigger[\s\S]*AFTER INSERT OR DELETE ON public\.likes[\s\S]*EXECUTE FUNCTION public\.handle_like_count_change\(\)/, - ) - }) - - // ------------------------------------------------------------------------- - // handle_bookmark_count_change - // ------------------------------------------------------------------------- - it('defines handle_bookmark_count_change trigger function', () => { - expect(migration).toMatch( - /CREATE OR REPLACE FUNCTION public\.handle_bookmark_count_change\(\)/, - ) - }) - - it('handle_bookmark_count_change is SECURITY DEFINER with locked search_path', () => { - expect(migration).toMatch( - /handle_bookmark_count_change[\s\S]*SECURITY DEFINER[\s\S]*SET search_path = public, pg_temp/, - ) - }) - - it('handle_bookmark_count_change increments on INSERT and decrements on DELETE', () => { - expect(migration).toMatch( - /handle_bookmark_count_change[\s\S]*TG_OP = 'INSERT'[\s\S]*bookmark_count\s*=\s*bookmark_count\s*\+\s*1[\s\S]*WHERE id = NEW\.post_id/, - ) - expect(migration).toMatch( - /handle_bookmark_count_change[\s\S]*TG_OP = 'DELETE'[\s\S]*bookmark_count\s*=\s*bookmark_count\s*-\s*1[\s\S]*WHERE id = OLD\.post_id/, - ) - }) - - it('creates AFTER INSERT OR DELETE trigger bookmarks_count_trigger on public.bookmarks', () => { - expect(migration).toMatch( - /CREATE TRIGGER bookmarks_count_trigger[\s\S]*AFTER INSERT OR DELETE ON public\.bookmarks[\s\S]*EXECUTE FUNCTION public\.handle_bookmark_count_change\(\)/, - ) - }) - - // ------------------------------------------------------------------------- - // handle_follow_count_change — single function, both sides - // ------------------------------------------------------------------------- - it('defines handle_follow_count_change trigger function', () => { - expect(migration).toMatch( - /CREATE OR REPLACE FUNCTION public\.handle_follow_count_change\(\)/, - ) - }) - - it('handle_follow_count_change is SECURITY DEFINER with locked search_path', () => { - expect(migration).toMatch( - /handle_follow_count_change[\s\S]*SECURITY DEFINER[\s\S]*SET search_path = public, pg_temp/, - ) - }) - - it('handle_follow_count_change INSERT increments follower_count for followed_id', () => { - expect(migration).toMatch( - /handle_follow_count_change[\s\S]*TG_OP = 'INSERT'[\s\S]*follower_count\s*=\s*follower_count\s*\+\s*1[\s\S]*WHERE id = NEW\.followed_id/, - ) - }) - - it('handle_follow_count_change INSERT increments following_count for follower_id', () => { - expect(migration).toMatch( - /handle_follow_count_change[\s\S]*TG_OP = 'INSERT'[\s\S]*following_count\s*=\s*following_count\s*\+\s*1[\s\S]*WHERE id = NEW\.follower_id/, - ) - }) - - it('handle_follow_count_change DELETE decrements follower_count for followed_id', () => { - expect(migration).toMatch( - /handle_follow_count_change[\s\S]*TG_OP = 'DELETE'[\s\S]*follower_count\s*=\s*follower_count\s*-\s*1[\s\S]*WHERE id = OLD\.followed_id/, - ) - }) - - it('handle_follow_count_change DELETE decrements following_count for follower_id', () => { - expect(migration).toMatch( - /handle_follow_count_change[\s\S]*TG_OP = 'DELETE'[\s\S]*following_count\s*=\s*following_count\s*-\s*1[\s\S]*WHERE id = OLD\.follower_id/, - ) - }) - - it('creates AFTER INSERT OR DELETE trigger follows_count_trigger on public.follows', () => { - expect(migration).toMatch( - /CREATE TRIGGER follows_count_trigger[\s\S]*AFTER INSERT OR DELETE ON public\.follows[\s\S]*EXECUTE FUNCTION public\.handle_follow_count_change\(\)/, - ) - }) - - // ------------------------------------------------------------------------- - // DROP TRIGGER IF EXISTS guards (idempotent re-run) - // ------------------------------------------------------------------------- - it('guards each CREATE TRIGGER with DROP TRIGGER IF EXISTS', () => { - expect(migration).toMatch( - /DROP TRIGGER IF EXISTS likes_count_trigger ON public\.likes/, - ) - expect(migration).toMatch( - /DROP TRIGGER IF EXISTS bookmarks_count_trigger ON public\.bookmarks/, - ) - expect(migration).toMatch( - /DROP TRIGGER IF EXISTS follows_count_trigger ON public\.follows/, - ) - }) - - // ------------------------------------------------------------------------- - // Backfills - // ------------------------------------------------------------------------- - it('backfills posts.like_count from existing likes', () => { - expect(migration).toMatch( - /UPDATE public\.posts[\s\S]*SET like_count[\s\S]*FROM public\.likes[\s\S]*l\.post_id = p\.id/, - ) - }) - - it('backfills posts.bookmark_count from existing bookmarks', () => { - expect(migration).toMatch( - /UPDATE public\.posts[\s\S]*SET bookmark_count[\s\S]*FROM public\.bookmarks[\s\S]*b\.post_id = p\.id/, - ) - }) - - it('backfills users.follower_count and following_count from existing follows', () => { - expect(migration).toMatch( - /UPDATE public\.users[\s\S]*SET follower_count[\s\S]*f\.followed_id = u\.id[\s\S]*following_count[\s\S]*f\.follower_id = u\.id/, - ) - }) -}) diff --git a/tests/unit/migration-0026.test.ts b/tests/unit/migration-0026.test.ts new file mode 100644 index 0000000..1ce134f --- /dev/null +++ b/tests/unit/migration-0026.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +let migration: string + +beforeAll(() => { + migration = readFileSync( + resolve(__dirname, '../../supabase/migrations/0026_drop_engagement.sql'), + 'utf8', + ) +}) + +describe('0026_drop_engagement.sql shape', () => { + // ------------------------------------------------------------------------- + // Guard-rail deletes (must precede the CHECK narrowing below) + // ------------------------------------------------------------------------- + it('deletes reports rows outside post/user before narrowing the CHECK', () => { + const deleteIdx = migration.indexOf("DELETE FROM public.reports WHERE target_type NOT IN ('post', 'user')") + const checkIdx = migration.indexOf('ADD CONSTRAINT reports_target_type_check') + expect(deleteIdx).toBeGreaterThan(-1) + expect(checkIdx).toBeGreaterThan(-1) + expect(deleteIdx).toBeLessThan(checkIdx) + }) + + it('uses NOT IN (not a plain = comment check) for the reports guard-rail delete', () => { + // The old reports CHECK (0017) also allowed 'org', which the narrowed + // CHECK here drops too, so a plain = 'comment' delete would leave a + // stray org-target row behind and fail ADD CONSTRAINT. + expect(migration).not.toMatch(/DELETE FROM public\.reports WHERE target_type = 'comment';/) + expect(migration).toMatch( + /DELETE FROM public\.reports WHERE target_type NOT IN \('post', 'user'\);/, + ) + }) + + it('deletes comment-target mod_actions before narrowing the CHECK', () => { + const deleteIdx = migration.indexOf("DELETE FROM public.mod_actions WHERE target_type = 'comment'") + const checkIdx = migration.indexOf('ADD CONSTRAINT mod_actions_target_type_check') + expect(deleteIdx).toBeGreaterThan(-1) + expect(checkIdx).toBeGreaterThan(-1) + expect(deleteIdx).toBeLessThan(checkIdx) + }) + + // ------------------------------------------------------------------------- + // Tables + // ------------------------------------------------------------------------- + it.each(['likes', 'comments', 'bookmarks', 'follows'])( + 'drops public.%s with CASCADE', + (table) => { + expect(migration).toMatch( + new RegExp(`DROP TABLE IF EXISTS public\\.${table} CASCADE;`), + ) + }, + ) + + // ------------------------------------------------------------------------- + // Columns + // ------------------------------------------------------------------------- + it.each(['view_count', 'like_count', 'bookmark_count', 'comment_count'])( + 'drops posts.%s', + (col) => { + expect(migration).toMatch( + new RegExp(`ALTER TABLE public\\.posts DROP COLUMN IF EXISTS ${col};`), + ) + }, + ) + + it.each(['follower_count', 'following_count'])('drops users.%s', (col) => { + expect(migration).toMatch( + new RegExp(`ALTER TABLE public\\.users DROP COLUMN IF EXISTS ${col};`), + ) + }) + + it('drops mod_actions.target_comment_id', () => { + expect(migration).toMatch( + /ALTER TABLE public\.mod_actions DROP COLUMN IF EXISTS target_comment_id;/, + ) + }) + + it('notes that dropping target_comment_id also retires its CHECK and partial index', () => { + const dropIdx = migration.indexOf('ALTER TABLE public.mod_actions DROP COLUMN IF EXISTS target_comment_id;') + expect(dropIdx).toBeGreaterThan(-1) + const preceding = migration.slice(Math.max(0, dropIdx - 500), dropIdx) + expect(preceding).toMatch(/mod_actions_target_single_typed/) + expect(preceding).toMatch(/mod_actions_target_comment_idx/) + }) + + // ------------------------------------------------------------------------- + // public.users_public view (must be dropped before, and recreated after, + // the users.follower_count/following_count column drops) + // ------------------------------------------------------------------------- + it('drops users_public before dropping the users count columns', () => { + const dropViewIdx = migration.indexOf('DROP VIEW IF EXISTS public.users_public;') + const dropFollowerIdx = migration.indexOf('ALTER TABLE public.users DROP COLUMN IF EXISTS follower_count;') + expect(dropViewIdx).toBeGreaterThan(-1) + expect(dropFollowerIdx).toBeGreaterThan(-1) + expect(dropViewIdx).toBeLessThan(dropFollowerIdx) + }) + + it('recreates users_public without follower_count/following_count', () => { + const viewMatch = migration.match( + /CREATE VIEW public\.users_public AS\s*SELECT([\s\S]*?)FROM public\.users;/, + ) + expect(viewMatch).not.toBeNull() + const columns = viewMatch![1] + expect(columns).not.toMatch(/follower_count/) + expect(columns).not.toMatch(/following_count/) + for (const col of ['id', 'username', 'display_name', 'bio', 'avatar_url', 'github_login', 'created_at']) { + expect(columns).toMatch(new RegExp(`\\b${col}\\b`)) + } + }) + + it('recreates users_public after the users count column drops', () => { + const dropFollowingIdx = migration.indexOf('ALTER TABLE public.users DROP COLUMN IF EXISTS following_count;') + const createViewIdx = migration.indexOf('CREATE VIEW public.users_public AS') + expect(dropFollowingIdx).toBeGreaterThan(-1) + expect(createViewIdx).toBeGreaterThan(-1) + expect(dropFollowingIdx).toBeLessThan(createViewIdx) + }) + + it('re-issues 0014\'s users_public grants exactly (REVOKE ALL then GRANT SELECT)', () => { + expect(migration).toMatch( + /REVOKE ALL ON public\.users_public FROM anon, authenticated;\s*GRANT SELECT ON public\.users_public TO anon, authenticated;/, + ) + }) + + // ------------------------------------------------------------------------- + // Functions + // ------------------------------------------------------------------------- + it.each([ + 'increment_post_view_count(uuid)', + 'comment_depth_for_parent(uuid)', + 'handle_comment_count_change()', + 'feed_shortlist_by_heat(integer)', + 'handle_like_count_change()', + 'handle_bookmark_count_change()', + 'handle_follow_count_change()', + ])('drops function %s', (signature) => { + const escaped = signature.replace(/[()]/g, (c) => `\\${c}`) + expect(migration).toMatch( + new RegExp(`DROP FUNCTION IF EXISTS public\\.${escaped};`), + ) + }) + + // ------------------------------------------------------------------------- + // CHECK narrowing + // ------------------------------------------------------------------------- + it('narrows reports.target_type to post/user only', () => { + expect(migration).toMatch( + /ALTER TABLE public\.reports DROP CONSTRAINT IF EXISTS reports_target_type_check;\s*ALTER TABLE public\.reports ADD CONSTRAINT reports_target_type_check\s*CHECK \(target_type IN \('post', 'user'\)\);/, + ) + }) + + it('narrows mod_actions.target_type by dropping only comment', () => { + expect(migration).toMatch( + /ALTER TABLE public\.mod_actions DROP CONSTRAINT IF EXISTS mod_actions_target_type_check;\s*ALTER TABLE public\.mod_actions ADD CONSTRAINT mod_actions_target_type_check\s*CHECK \(target_type IN \('post', 'user', 'tag', 'report', 'org'\)\);/, + ) + // 'comment' must be gone from the narrowed set. + const constraintMatch = migration.match( + /ADD CONSTRAINT mod_actions_target_type_check\s*CHECK \(target_type IN \(([^)]+)\)\);/, + ) + expect(constraintMatch).not.toBeNull() + expect(constraintMatch![1]).not.toContain("'comment'") + // org survives (still written by app/api/admin/orgs/ban and unban). + expect(constraintMatch![1]).toContain("'org'") + }) + + // ------------------------------------------------------------------------- + // search_posts recreation + // ------------------------------------------------------------------------- + it('drops the old search_posts signature before recreating it', () => { + expect(migration).toMatch( + /DROP FUNCTION IF EXISTS public\.search_posts\(text, integer, text, text\[\]\);/, + ) + }) + + it('recreates search_posts without like_count/bookmark_count/comment_count', () => { + const fnMatch = migration.match( + /CREATE FUNCTION public\.search_posts\([\s\S]*?\$\$;/, + ) + expect(fnMatch).not.toBeNull() + const fnBody = fnMatch![0] + expect(fnBody).not.toMatch(/like_count/) + expect(fnBody).not.toMatch(/bookmark_count/) + expect(fnBody).not.toMatch(/comment_count/) + }) + + it('keeps the id/author_id/type/slug/title/summary/snippet/published_at/rank return shape', () => { + const returnsMatch = migration.match( + /CREATE FUNCTION public\.search_posts\([\s\S]*?RETURNS TABLE \(([\s\S]*?)\)/, + ) + expect(returnsMatch).not.toBeNull() + const columns = returnsMatch![1] + for (const col of [ + 'id', + 'author_id', + 'type', + 'slug', + 'title', + 'summary', + 'snippet', + 'published_at', + 'rank', + ]) { + expect(columns).toMatch(new RegExp(`\\b${col}\\b`)) + } + }) + + it('is SECURITY DEFINER with locked search_path', () => { + expect(migration).toMatch(/CREATE FUNCTION public\.search_posts[\s\S]*SECURITY DEFINER/) + expect(migration).toMatch(/CREATE FUNCTION public\.search_posts[\s\S]*SET search_path = public, pg_temp/) + }) + + it('grants EXECUTE on the recreated search_posts to anon, authenticated, service_role', () => { + expect(migration).toMatch( + /GRANT EXECUTE ON FUNCTION public\.search_posts\(text, integer, text, text\[\]\)\s*TO anon, authenticated, service_role;/, + ) + }) + + // ------------------------------------------------------------------------- + // Untouched objects: no DDL statement (ALTER/DROP/CREATE) may target + // these; the header comment is allowed to reference them by name for + // documentation, so we scan statements rather than the raw file text. + // ------------------------------------------------------------------------- + it('issues no DDL against the surviving approval-gate and index objects', () => { + const ddlLines = migration + .split('\n') + .filter((line) => /^(ALTER|DROP|CREATE)\b/i.test(line.trim())) + const ddlText = ddlLines.join('\n') + for (const name of [ + 'posts_published_idx', + 'posts_require_approved', + 'reports_require_approved', + 'pinned_posts_require_approved', + 'resolve_session_gate', + 'require_approved_user', + 'enforce_author_approved', + ]) { + expect(ddlText, `DDL should not reference ${name}`).not.toContain(name) + } + }) +}) diff --git a/tests/unit/orgs-routing.test.ts b/tests/unit/orgs-routing.test.ts index a7e6a74..71cc73e 100644 --- a/tests/unit/orgs-routing.test.ts +++ b/tests/unit/orgs-routing.test.ts @@ -114,9 +114,6 @@ describe('lookupPost (org branch)', () => { body_html: '

hi

', cover_image_url: null, structured_sections: null, - view_count: 0, - comment_count: 0, - like_count: 0, published_at: '2026-01-01T00:00:00Z', edited_at: null, deleted_at: null, diff --git a/tests/unit/orgs-ui.test.tsx b/tests/unit/orgs-ui.test.tsx index 5e16cf0..108f7d4 100644 --- a/tests/unit/orgs-ui.test.tsx +++ b/tests/unit/orgs-ui.test.tsx @@ -109,9 +109,6 @@ describe(' byline', () => { title: 'Hello', summary: 'A summary', published_at: '2026-06-01T00:00:00Z', - like_count: 0, - bookmark_count: 0, - comment_count: 0, author: { username: 'alice', display_name: 'Alice', diff --git a/tests/unit/pages/home.empty-state.test.tsx b/tests/unit/pages/home.empty-state.test.tsx index 7810b49..0ec3a4e 100644 --- a/tests/unit/pages/home.empty-state.test.tsx +++ b/tests/unit/pages/home.empty-state.test.tsx @@ -1,12 +1,11 @@ /** - * Phase 13 — home page empty state. + * Home page empty state. * * The home `/` page wraps its feed query in a Suspense boundary; the * empty state lives inside the `FeedList` server component which renders - * after `getForYouFeed` / `getLatestFeed` returns zero rows. To assert - * the copy without spinning up Supabase, we walk the server-tree, locate - * the Suspense child (the async `FeedList`), and await it directly with - * its resolved props. + * after `getLatestFeed` returns zero rows. To assert the copy without + * spinning up Supabase, we walk the server-tree, locate the Suspense + * child (the async `FeedList`), and await it directly. */ import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' @@ -15,34 +14,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // Module mocks — declared before any imports that trigger them // --------------------------------------------------------------------------- -vi.mock('@/lib/auth', () => ({ - getSession: vi.fn(), -})) - -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(() => ({})), -})) - vi.mock('@/lib/supabase/server', () => ({ createAnonServerSupabaseClient: vi.fn(() => ({})), })) vi.mock('@/lib/feed', () => ({ - getForYouFeed: vi.fn(), getLatestFeed: vi.fn(), })) vi.mock('@/lib/feed/hydrate', () => ({ fetchAuthors: vi.fn(async () => new Map()), fetchOrgsByPost: vi.fn(async () => new Map()), - fetchTagNames: vi.fn(async () => new Map()), fetchTagsByPost: vi.fn(async () => new Map()), })) -// Mock discovery-cache so TrendingStrip/RightSidebar don't call unstable_cache +// Mock discovery-cache so TopByType/RightSidebar don't call unstable_cache // in jsdom (unstable_cache requires a Next.js incrementalCache context). vi.mock('@/lib/feed/discovery-cache', () => ({ - cachedTrendingTags: vi.fn(async () => []), cachedTopPlaybooks: vi.fn(async () => []), cachedTopDives: vi.fn(async () => []), })) @@ -51,12 +39,11 @@ vi.mock('@/lib/feed/discovery-cache', () => ({ // Imports after mocks // --------------------------------------------------------------------------- -import { getSession } from '@/lib/auth' -import { getForYouFeed, getLatestFeed } from '@/lib/feed' +import { getLatestFeed } from '@/lib/feed' import HomePage from '@/app/page' // --------------------------------------------------------------------------- -// Tree helpers (shared shape with other phase-13 page tests) +// Tree helpers (shared shape with other page tests) // --------------------------------------------------------------------------- function collectText(node: React.ReactNode): string { @@ -119,10 +106,7 @@ function findAllByComponentType( // --------------------------------------------------------------------------- async function renderHomeFeed(): Promise { - const tree = await HomePage() - // Phase B added TrendingStrip and TopByType wrapped in Suspense before and - // after the FeedList Suspense. Find the Suspense whose child is FeedList - // (identified by its function name 'FeedList'). + const tree = HomePage() const allSuspenses = findAllByComponentType(tree, React.Suspense) const feedListSuspense = allSuspenses.find((s) => { const child = (s.props as { children?: React.ReactElement }).children @@ -142,35 +126,18 @@ async function renderHomeFeed(): Promise { describe('HomePage empty state', () => { beforeEach(() => { - vi.mocked(getSession).mockReset() - vi.mocked(getForYouFeed).mockReset() vi.mocked(getLatestFeed).mockReset() }) - it('renders the warming-up copy when the anon Latest feed is empty', async () => { - vi.mocked(getSession).mockResolvedValue(null) + it('renders neutral copy pointing at /tags when the Latest feed is empty', async () => { vi.mocked(getLatestFeed).mockResolvedValue([]) const body = await renderHomeFeed() const text = collectText(body) - expect(text).toContain('Follow people or wait while the feed warms up.') expect(text).toContain('/tags') }) - it('renders the warming-up copy when the authed For-You feed is empty', async () => { - vi.mocked(getSession).mockResolvedValue({ - user: { id: 'user-1', name: 'A', email: 'a@example.com' }, - expires: '2099-12-31T23:59:59.000Z', - }) - vi.mocked(getForYouFeed).mockResolvedValue([]) - - const body = await renderHomeFeed() - const text = collectText(body) - expect(text).toContain('Follow people or wait while the feed warms up.') - }) - it('the empty-state action links to /tags', async () => { - vi.mocked(getSession).mockResolvedValue(null) vi.mocked(getLatestFeed).mockResolvedValue([]) const body = await renderHomeFeed() diff --git a/tests/unit/pages/profile.empty-state.test.tsx b/tests/unit/pages/profile.empty-state.test.tsx index d174542..79e7030 100644 --- a/tests/unit/pages/profile.empty-state.test.tsx +++ b/tests/unit/pages/profile.empty-state.test.tsx @@ -36,8 +36,6 @@ const POST_FIXTURE: ProfilePostCardData = { summary: 'A summary', cover_image_url: null, published_at: '2026-02-01T00:00:00Z', - view_count: 5, - comment_count: 0, tags: [], } diff --git a/tests/unit/posts/lookup.test.ts b/tests/unit/posts/lookup.test.ts index 50a03b1..9206597 100644 --- a/tests/unit/posts/lookup.test.ts +++ b/tests/unit/posts/lookup.test.ts @@ -79,8 +79,6 @@ const POST_ROW = { body_html: '

Hello

', cover_image_url: null, structured_sections: null, - view_count: 42, - comment_count: 7, published_at: '2026-01-01T00:00:00Z', edited_at: null, deleted_at: null, @@ -158,7 +156,6 @@ describe('lookupPost', () => { expect(result?.type).toBe('post') expect(result?.slug).toBe('my-great-post') expect(result?.title).toBe('My Great Post') - expect(result?.view_count).toBe(42) expect(result?.author).toEqual({ id: 'user-1', username: 'alice', diff --git a/tests/unit/posts/post-page.test.tsx b/tests/unit/posts/post-page.test.tsx index 1545081..cceeb3f 100644 --- a/tests/unit/posts/post-page.test.tsx +++ b/tests/unit/posts/post-page.test.tsx @@ -40,38 +40,13 @@ vi.mock('@/components/posts/MermaidHydratorClient', () => ({ vi.mock('@/components/posts/StructuredSections', () => ({ StructuredSections: () => React.createElement('div', { 'data-testid': 'structured-sections' }), })) -vi.mock('@/components/posts/ViewBeacon', () => ({ - ViewBeacon: () => React.createElement('div', { 'data-testid': 'view-beacon' }), -})) vi.mock('@/components/posts/Backlinks', () => ({ Backlinks: () => React.createElement('div', { 'data-testid': 'backlinks' }), })) -vi.mock('@/components/post/CommentsSection', () => ({ - CommentsSection: () => - React.createElement('div', { 'data-testid': 'comments-section' }), -})) vi.mock('@/components/posts/AuthorActions', () => ({ AuthorActions: ({ postId }: { postId: string }) => React.createElement('div', { 'data-testid': 'author-actions', 'data-post-id': postId }), })) -vi.mock('@/components/post/LikeButton', () => ({ - LikeButton: () => React.createElement('div', { 'data-testid': 'like-button' }), -})) -vi.mock('@/components/post/BookmarkButton', () => ({ - BookmarkButton: () => React.createElement('div', { 'data-testid': 'bookmark-button' }), -})) -vi.mock('@/lib/posts/engagement', () => ({ - getEngagementState: vi.fn(async () => ({ liked: false, bookmarked: false })), -})) -vi.mock('@/lib/profile/follow-state', () => ({ - getFollowState: vi.fn(async () => false), -})) -vi.mock('@/components/profile/FollowButton', () => ({ - FollowButton: () => React.createElement('div', { 'data-testid': 'follow-button' }), -})) -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(() => ({})), -})) vi.mock('next/link', () => ({ default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => React.createElement('a', { href, className }, children), @@ -141,9 +116,6 @@ const BASE_POST: LookedUpPost = { body_html: '

Hello

', cover_image_url: null, structured_sections: null, - view_count: 10, - comment_count: 0, - like_count: 0, published_at: '2026-01-01T00:00:00Z', edited_at: null, author: { diff --git a/tests/unit/posts/view-beacon.test.tsx b/tests/unit/posts/view-beacon.test.tsx deleted file mode 100644 index 392d442..0000000 --- a/tests/unit/posts/view-beacon.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render } from '@testing-library/react' -import { ViewBeacon } from '@/components/posts/ViewBeacon' - -// jsdom localStorage is available but we spy on it for clarity -const POST_ID = 'test-post-123' -const LS_KEY = `agentlab.viewed.${POST_ID}` - -// Mock Next.js navigation (needed by any indirect imports) -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: vi.fn() }), -})) - -beforeEach(() => { - localStorage.clear() - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) -}) - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('ViewBeacon', () => { - it('fires POST /api/posts//view and writes localStorage on first mount (no entry)', async () => { - expect(localStorage.getItem(LS_KEY)).toBeNull() - render() - - // Wait for the microtask / effect to flush - await vi.waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - `/api/posts/${POST_ID}/view`, - expect.objectContaining({ method: 'POST', keepalive: true }), - ) - }) - - const stored = localStorage.getItem(LS_KEY) - expect(stored).not.toBeNull() - expect(() => new Date(stored!)).not.toThrow() - }) - - it('does NOT fire fetch when localStorage entry is within 24h', async () => { - // Set a timestamp 1 hour ago - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString() - localStorage.setItem(LS_KEY, oneHourAgo) - - render() - - // Give effects time to run - await new Promise((r) => setTimeout(r, 0)) - - expect(fetch).not.toHaveBeenCalled() - }) - - it('fires fetch when localStorage entry is older than 24h', async () => { - // Set a timestamp 25 hours ago - const twentyFiveHoursAgo = new Date( - Date.now() - 25 * 60 * 60 * 1000, - ).toISOString() - localStorage.setItem(LS_KEY, twentyFiveHoursAgo) - - render() - - await vi.waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - `/api/posts/${POST_ID}/view`, - expect.objectContaining({ method: 'POST', keepalive: true }), - ) - }) - - // Also updates localStorage - const stored = localStorage.getItem(LS_KEY) - expect(stored).not.toBe(twentyFiveHoursAgo) - }) - - it('fires fetch when localStorage entry is corrupt (treats as no entry)', async () => { - // Set a corrupt value that cannot be parsed as a date - localStorage.setItem(LS_KEY, 'not-a-date') - - render() - - await vi.waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - `/api/posts/${POST_ID}/view`, - expect.objectContaining({ method: 'POST', keepalive: true }), - ) - }) - }) -}) diff --git a/tests/unit/posts/wikilinks-resolve.test.ts b/tests/unit/posts/wikilinks-resolve.test.ts index 0cf7b1b..6ad1de5 100644 --- a/tests/unit/posts/wikilinks-resolve.test.ts +++ b/tests/unit/posts/wikilinks-resolve.test.ts @@ -7,7 +7,6 @@ interface Row { username: string type: 'post' | 'playbook' | 'dive' slug: string - like_count: number published_at: string org_id?: string | null org_slug?: string | null @@ -21,7 +20,6 @@ function rowToDbShape(r: Row) { slug: r.slug, type: r.type, published_at: r.published_at, - like_count: r.like_count, users: { username: r.username }, orgs: r.org_slug ? { slug: r.org_slug } : null, } @@ -44,7 +42,7 @@ describe('resolveAnchor', () => { expect(res).toBeNull() }) - it('prefers own post even when other posts have more likes', async () => { + it('prefers own post even when other posts are newer', async () => { const db = mockDb([ { id: 'p-mine', @@ -52,16 +50,14 @@ describe('resolveAnchor', () => { username: 'me', type: 'post', slug: 'shared-slug', - like_count: 1, published_at: '2026-01-01T00:00:00Z', }, { - id: 'p-popular', + id: 'p-newer', author_id: 'user-other', username: 'pop', type: 'post', slug: 'shared-slug', - like_count: 99, published_at: '2026-05-01T00:00:00Z', }, ]) @@ -69,53 +65,27 @@ describe('resolveAnchor', () => { expect(res?.targetPostId).toBe('p-mine') }) - it('uses likes tiebreak when no own post', async () => { + it('uses newest published_at as tiebreak when no own post', async () => { const db = mockDb([ { - id: 'p-old-pop', + id: 'p-older', author_id: 'a', username: 'a', type: 'post', slug: 's', - like_count: 50, published_at: '2026-01-01T00:00:00Z', - }, + like_count: 50, // Discriminate: old ranking would pick this on high likes; new ranking picks p-newer on recency + } as unknown as Row, { - id: 'p-newer-cold', + id: 'p-newer', author_id: 'b', username: 'b', type: 'post', slug: 's', - like_count: 1, published_at: '2026-05-01T00:00:00Z', }, ]) const res = await resolveAnchor('S', { db: db as never, currentUserId: me }) - expect(res?.targetPostId).toBe('p-old-pop') - }) - - it('falls back to recency when likes are tied', async () => { - const db = mockDb([ - { - id: 'p-newer', - author_id: 'a', - username: 'a', - type: 'dive', - slug: 's', - like_count: 0, - published_at: '2026-05-01T00:00:00Z', - }, - { - id: 'p-older', - author_id: 'b', - username: 'b', - type: 'dive', - slug: 's', - like_count: 0, - published_at: '2026-01-01T00:00:00Z', - }, - ]) - const res = await resolveAnchor('S', { db: db as never, currentUserId: me }) expect(res?.targetPostId).toBe('p-newer') }) @@ -127,7 +97,6 @@ describe('resolveAnchor', () => { username: 'alice', type: 'playbook', slug: 'agent-memory', - like_count: 0, published_at: '2026-01-01T00:00:00Z', }, ]) @@ -153,7 +122,6 @@ describe('resolveAnchor', () => { org_slug: 'acme', type: 'post', slug: 'rag-eval', - like_count: 0, published_at: '2026-01-01T00:00:00Z', }, ]) diff --git a/tests/unit/profile/follow-state.test.ts b/tests/unit/profile/follow-state.test.ts deleted file mode 100644 index e052923..0000000 --- a/tests/unit/profile/follow-state.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { getFollowState } from '@/lib/profile/follow-state' - -type SingleResult = { - data: Record | null - error: unknown -} - -function makeChain(result: SingleResult) { - const chain = { - select: vi.fn(), - eq: vi.fn(), - maybeSingle: vi.fn(() => Promise.resolve(result)), - } - chain.select.mockReturnValue(chain) - chain.eq.mockReturnValue(chain) - return chain -} - -function makeClient(chain: ReturnType) { - return { from: vi.fn(() => chain) } -} - -describe('getFollowState', () => { - it('returns false without a DB call when viewerUserId is null (anon)', async () => { - const chain = makeChain({ data: { follower_id: 'x' }, error: null }) - const db = makeClient(chain) - const result = await getFollowState({ - admin: db as never, - targetUserId: 'user-1', - viewerUserId: null, - }) - expect(result).toBe(false) - expect(db.from).not.toHaveBeenCalled() - }) - - it('returns false without a DB call when viewerUserId is undefined (anon)', async () => { - const chain = makeChain({ data: { follower_id: 'x' }, error: null }) - const db = makeClient(chain) - const result = await getFollowState({ - admin: db as never, - targetUserId: 'user-1', - viewerUserId: undefined, - }) - expect(result).toBe(false) - expect(db.from).not.toHaveBeenCalled() - }) - - it('returns false without a DB call when viewer === target (self)', async () => { - const chain = makeChain({ data: { follower_id: 'x' }, error: null }) - const db = makeClient(chain) - const result = await getFollowState({ - admin: db as never, - targetUserId: 'user-1', - viewerUserId: 'user-1', - }) - expect(result).toBe(false) - expect(db.from).not.toHaveBeenCalled() - }) - - it('returns true when a follows row exists', async () => { - const chain = makeChain({ - data: { follower_id: 'viewer-1' }, - error: null, - }) - const db = makeClient(chain) - const result = await getFollowState({ - admin: db as never, - targetUserId: 'user-1', - viewerUserId: 'viewer-1', - }) - expect(result).toBe(true) - expect(db.from).toHaveBeenCalledWith('follows') - expect(chain.eq).toHaveBeenCalledWith('follower_id', 'viewer-1') - expect(chain.eq).toHaveBeenCalledWith('followed_id', 'user-1') - }) - - it('returns false when no row matches', async () => { - const chain = makeChain({ data: null, error: null }) - const db = makeClient(chain) - const result = await getFollowState({ - admin: db as never, - targetUserId: 'user-1', - viewerUserId: 'viewer-1', - }) - expect(result).toBe(false) - }) - - it('returns false (fail-safe) when Supabase returns an error', async () => { - const chain = makeChain({ data: null, error: { message: 'boom' } }) - const db = makeClient(chain) - const result = await getFollowState({ - admin: db as never, - targetUserId: 'user-1', - viewerUserId: 'viewer-1', - }) - expect(result).toBe(false) - }) -}) diff --git a/tests/unit/profile/lookup.test.ts b/tests/unit/profile/lookup.test.ts index e7a7175..8e156e1 100644 --- a/tests/unit/profile/lookup.test.ts +++ b/tests/unit/profile/lookup.test.ts @@ -69,8 +69,6 @@ const USER_ROW = { avatar_url: 'https://example.com/a.jpg', github_login: 'Alice', created_at: '2026-01-01T00:00:00Z', - follower_count: 12, - following_count: 3, } const POST_ROW = { @@ -81,8 +79,6 @@ const POST_ROW = { summary: 'Summary', cover_image_url: null, published_at: '2026-02-01T00:00:00Z', - view_count: 5, - comment_count: 2, deleted_at: null, post_tags: [ { tag_slug: 'agents', tags: { slug: 'agents', name: 'Agents', is_approved: true } }, @@ -128,8 +124,6 @@ describe('lookupProfileByUsername', () => { avatar_url: 'https://example.com/a.jpg', github_login: 'Alice', created_at: '2026-01-01T00:00:00Z', - follower_count: 12, - following_count: 3, }) expect(db.from).toHaveBeenCalledWith('users_public') expect(chain.select).toHaveBeenCalledWith( diff --git a/tests/unit/profile/page.test.tsx b/tests/unit/profile/page.test.tsx index 5e4c725..7c28234 100644 --- a/tests/unit/profile/page.test.tsx +++ b/tests/unit/profile/page.test.tsx @@ -32,14 +32,6 @@ vi.mock('@/lib/supabase/server', () => ({ createAnonServerSupabaseClient: vi.fn(() => ({ from: vi.fn() })), })) -vi.mock('@/lib/supabase/admin', () => ({ - createAdminSupabaseClient: vi.fn(() => ({ from: vi.fn() })), -})) - -vi.mock('@/lib/profile/follow-state', () => ({ - getFollowState: vi.fn(async () => false), -})) - vi.mock('next/navigation', () => ({ notFound: vi.fn(() => { throw new Error('NEXT_NOT_FOUND') @@ -98,8 +90,6 @@ const BASE_PROFILE: ProfileUser = { avatar_url: null, github_login: 'Alice', created_at: '2026-01-01T00:00:00Z', - follower_count: 0, - following_count: 0, } // Walk the rendered tree looking for the first React element whose `type` diff --git a/tests/unit/rate-limit.test.ts b/tests/unit/rate-limit.test.ts index cfef948..aaa0141 100644 --- a/tests/unit/rate-limit.test.ts +++ b/tests/unit/rate-limit.test.ts @@ -41,17 +41,17 @@ describe('checkRateLimit (in-memory fallback)', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) - // Saturate the engagement bucket (60/min). + // Saturate the mdx_preview bucket (60/min). for (let i = 0; i < 60; i++) { - const r = await checkRateLimit('engagement', 'user:gamma') + const r = await checkRateLimit('mdx_preview', 'user:gamma') expect(r.success).toBe(true) } - const blocked = await checkRateLimit('engagement', 'user:gamma') + const blocked = await checkRateLimit('mdx_preview', 'user:gamma') expect(blocked.success).toBe(false) // Advance past the 1-minute window. vi.setSystemTime(new Date('2026-01-01T00:01:01Z')) - const recovered = await checkRateLimit('engagement', 'user:gamma') + const recovered = await checkRateLimit('mdx_preview', 'user:gamma') expect(recovered.success).toBe(true) }) }) @@ -61,8 +61,6 @@ describe('checkRateLimit bucket limits', () => { { bucket: 'publish', limit: 10 }, { bucket: 'edit_post', limit: 30 }, { bucket: 'delete_post', limit: 30 }, - { bucket: 'comment', limit: 30 }, - { bucket: 'engagement', limit: 60 }, { bucket: 'report', limit: 10 }, { bucket: 'image_upload', limit: 20 }, ] diff --git a/tests/unit/search/run.test.ts b/tests/unit/search/run.test.ts index b4924be..4186940 100644 --- a/tests/unit/search/run.test.ts +++ b/tests/unit/search/run.test.ts @@ -30,9 +30,6 @@ const SAMPLE_HIT: SearchHit = { summary: 'summary', snippet: 'snippet', published_at: '2026-05-29T00:00:00.000Z', - like_count: 2, - bookmark_count: 1, - comment_count: 0, rank: 0.42, }