Compare commits

...
Author SHA1 Message Date
Peter Steinberger edd83fdf85 test(comments): add updatedAt invalidation regression coverage 2026-02-14 01:50:03 +01:00
Seth RaphaelandClaude Opus 4.5 d8c7250cf2 fix(comments): stop updating skills.updatedAt on comment add/remove
Comments are not content changes, so they shouldn't invalidate skill
list queries that depend on updatedAt. This reduces query invalidation
when users add or remove comments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:15:44 -08:00
2 changed files with 144 additions and 55 deletions
+76
View File
@@ -0,0 +1,76 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('./lib/access', () => ({
assertRole: vi.fn(),
requireUser: vi.fn(),
}))
const { requireUser } = await import('./lib/access')
const { __test } = await import('./comments')
describe('comments mutations', () => {
afterEach(() => {
vi.mocked(requireUser).mockReset()
})
it('add updates comment count without touching updatedAt', async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:1',
user: { _id: 'users:1', role: 'user' },
} as never)
const get = vi.fn().mockResolvedValue({
_id: 'skills:1',
stats: { comments: 2 },
})
const insert = vi.fn()
const patch = vi.fn()
const ctx = { db: { get, insert, patch } } as never
await __test.addHandler(ctx, { skillId: 'skills:1', body: ' hello ' } as never)
expect(patch).toHaveBeenCalledTimes(1)
expect(patch).toHaveBeenCalledWith('skills:1', {
stats: { comments: 3 },
})
const skillPatch = vi.mocked(patch).mock.calls[0]?.[1] as Record<string, unknown>
expect(skillPatch.updatedAt).toBeUndefined()
})
it('remove updates comment count without touching updatedAt', async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:2',
user: { _id: 'users:2', role: 'moderator' },
} as never)
const comment = {
_id: 'comments:1',
skillId: 'skills:1',
userId: 'users:2',
softDeletedAt: undefined,
}
const skill = {
_id: 'skills:1',
stats: { comments: 4 },
}
const get = vi.fn(async (id: string) => {
if (id === 'comments:1') return comment
if (id === 'skills:1') return skill
return null
})
const insert = vi.fn()
const patch = vi.fn()
const ctx = { db: { get, insert, patch } } as never
await __test.removeHandler(ctx, { commentId: 'comments:1' } as never)
expect(patch).toHaveBeenCalledTimes(2)
expect(patch).toHaveBeenNthCalledWith(2, 'skills:1', {
stats: { comments: 3 },
})
const skillPatch = vi.mocked(patch).mock.calls[1]?.[1] as Record<string, unknown>
expect(skillPatch.updatedAt).toBeUndefined()
})
})
+68 -55
View File
@@ -3,6 +3,67 @@ import type { Doc } from './_generated/dataModel'
import { mutation, query } from './_generated/server'
import { assertRole, requireUser } from './lib/access'
async function addHandler(
ctx: import('./_generated/server').MutationCtx,
args: { skillId: import('./_generated/dataModel').Id<'skills'>; body: string },
) {
const { userId } = await requireUser(ctx)
const body = args.body.trim()
if (!body) throw new Error('Comment body required')
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.insert('comments', {
skillId: args.skillId,
userId,
body,
createdAt: Date.now(),
softDeletedAt: undefined,
deletedBy: undefined,
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
})
}
async function removeHandler(
ctx: import('./_generated/server').MutationCtx,
args: { commentId: import('./_generated/dataModel').Id<'comments'> },
) {
const { user } = await requireUser(ctx)
const comment = await ctx.db.get(args.commentId)
if (!comment) throw new Error('Comment not found')
if (comment.softDeletedAt) return
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
}
await ctx.db.patch(comment._id, {
softDeletedAt: Date.now(),
deletedBy: user._id,
})
const skill = await ctx.db.get(comment.skillId)
if (skill) {
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
})
}
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'comment.delete',
targetType: 'comment',
targetId: comment._id,
metadata: { skillId: comment.skillId },
createdAt: Date.now(),
})
}
export const listBySkill = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
@@ -25,63 +86,15 @@ export const listBySkill = query({
export const add = mutation({
args: { skillId: v.id('skills'), body: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const body = args.body.trim()
if (!body) throw new Error('Comment body required')
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.insert('comments', {
skillId: args.skillId,
userId,
body,
createdAt: Date.now(),
softDeletedAt: undefined,
deletedBy: undefined,
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
updatedAt: Date.now(),
})
},
handler: addHandler,
})
export const remove = mutation({
args: { commentId: v.id('comments') },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
const comment = await ctx.db.get(args.commentId)
if (!comment) throw new Error('Comment not found')
if (comment.softDeletedAt) return
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
}
await ctx.db.patch(comment._id, {
softDeletedAt: Date.now(),
deletedBy: user._id,
})
const skill = await ctx.db.get(comment.skillId)
if (skill) {
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
updatedAt: Date.now(),
})
}
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'comment.delete',
targetType: 'comment',
targetId: comment._id,
metadata: { skillId: comment.skillId },
createdAt: Date.now(),
})
},
handler: removeHandler,
})
export const __test = {
addHandler,
removeHandler,
}