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,
}