From f65e2812a66741836242051486d6c872223aff06 Mon Sep 17 00:00:00 2001 From: anshuman-krishna Date: Mon, 27 Apr 2026 05:09:50 +0530 Subject: [PATCH] error tracking --- jest.config.ts | 3 + src/app/api/collections/[id]/items/route.ts | 10 +- src/app/api/collections/route.ts | 5 +- .../api/messages/[conversationId]/route.ts | 31 +++-- src/app/api/posts/[id]/repost/route.ts | 10 +- src/app/api/users/online/route.ts | 25 ++++ src/app/app/shops/[city]/page.tsx | 4 +- src/backend/realtime/socket-server.ts | 36 +++++- src/backend/services/aftercare-service.ts | 7 +- src/backend/services/ai-service.ts | 58 +++++++++ .../services/price-estimator-service.ts | 4 +- .../features/coverup/coverup-form.tsx | 6 +- src/utils/mentions.ts | 5 +- tests/services/collection-service.test.ts | 111 ++++++++++++++++++ .../services/price-estimator-service.test.ts | 6 +- tests/services/repost-service.test.ts | 58 +++++++++ .../__snapshots__/api-contract.test.ts.snap | 67 +++++++++++ tests/unit/lib/feature-flags.test.ts | 3 + .../utils/ai-prompt-builder-coverup.test.ts | 32 +++++ tests/unit/utils/mentions.test.ts | 42 +++++++ 20 files changed, 479 insertions(+), 44 deletions(-) create mode 100644 src/app/api/users/online/route.ts create mode 100644 tests/services/collection-service.test.ts create mode 100644 tests/services/repost-service.test.ts create mode 100644 tests/unit/utils/ai-prompt-builder-coverup.test.ts create mode 100644 tests/unit/utils/mentions.test.ts diff --git a/jest.config.ts b/jest.config.ts index 4f53923..3901d52 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -24,6 +24,9 @@ const config: Config = { 'src/backend/services/notification-service.ts', 'src/backend/services/ar-preview-service.ts', 'src/backend/services/audit-log-service.ts', + 'src/backend/services/price-estimator-service.ts', + 'src/backend/services/repost-service.ts', + 'src/backend/services/collection-service.ts', '!src/**/*.d.ts', '!src/**/index.ts', ], diff --git a/src/app/api/collections/[id]/items/route.ts b/src/app/api/collections/[id]/items/route.ts index 7055fc8..7a8631b 100644 --- a/src/app/api/collections/[id]/items/route.ts +++ b/src/app/api/collections/[id]/items/route.ts @@ -42,7 +42,10 @@ export const POST = withErrorHandler(async (req: Request, ctx: unknown) => { return withRequestId(req, NextResponse.json({ success: true, item }, { status: 201 })); } catch (err) { const message = err instanceof Error ? err.message : 'Failed'; - return withRequestId(req, NextResponse.json({ success: false, error: message }, { status: 404 })); + return withRequestId( + req, + NextResponse.json({ success: false, error: message }, { status: 404 }), + ); } }, 'POST /api/collections/[id]/items'); @@ -66,6 +69,9 @@ export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => { await removeCollectionItem(itemId, session.user.id); return withRequestId(req, NextResponse.json({ success: true })); } catch { - return withRequestId(req, NextResponse.json({ success: false, error: 'Item not found' }, { status: 404 })); + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Item not found' }, { status: 404 }), + ); } }, 'DELETE /api/collections/[id]/items'); diff --git a/src/app/api/collections/route.ts b/src/app/api/collections/route.ts index bd7dc25..219d702 100644 --- a/src/app/api/collections/route.ts +++ b/src/app/api/collections/route.ts @@ -5,10 +5,7 @@ import { authGuard } from '@/backend/middleware/auth-guard'; import { withErrorHandler } from '@/lib/api-error'; import { withRequestId } from '@/backend/middleware/request-log'; import { rateLimit } from '@/backend/middleware/rate-limit'; -import { - createCollection, - getCollectionsForOwner, -} from '@/backend/services/collection-service'; +import { createCollection, getCollectionsForOwner } from '@/backend/services/collection-service'; import { createCollectionSchema, paginationSchema } from '@/lib/validations'; import { getPaginationParams } from '@/utils/pagination'; diff --git a/src/app/api/messages/[conversationId]/route.ts b/src/app/api/messages/[conversationId]/route.ts index 504e8fd..6f7b029 100644 --- a/src/app/api/messages/[conversationId]/route.ts +++ b/src/app/api/messages/[conversationId]/route.ts @@ -2,11 +2,14 @@ export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { getMessages, getConversationById, markConversationRead, } from '@/backend/services/message-service'; +import { emitConversationRead } from '@/backend/realtime/socket-server'; import { getPaginationParams } from '@/utils/pagination'; import { paginationSchema } from '@/lib/validations'; @@ -15,9 +18,10 @@ interface Params { } // get messages for conversation -export async function GET(req: Request, { params }: Params) { +export const GET = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const { conversationId } = await params; const { searchParams } = new URL(req.url); @@ -30,10 +34,9 @@ export async function GET(req: Request, { params }: Params) { const limit = parsed.success ? parsed.data.limit : 50; try { - // verify membership before fetching const conversation = await getConversationById(conversationId, session.user.id); if (!conversation) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return withRequestId(req, NextResponse.json({ error: 'Forbidden' }, { status: 403 })); } const result = await getMessages( @@ -42,24 +45,26 @@ export async function GET(req: Request, { params }: Params) { getPaginationParams(page, limit), ); - return NextResponse.json({ conversation, ...result }); + return withRequestId(req, NextResponse.json({ conversation, ...result })); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed'; - return NextResponse.json({ error: msg }, { status: 403 }); + return withRequestId(req, NextResponse.json({ error: msg }, { status: 403 })); } -} +}, 'GET /api/messages/[conversationId]'); -// mark conversation as read -export async function PATCH(_req: Request, { params }: Params) { +// mark conversation as read + broadcast receipt +export const PATCH = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const { conversationId } = await params; try { await markConversationRead(conversationId, session.user.id); - return NextResponse.json({ success: true }); + emitConversationRead(conversationId, session.user.id); + return withRequestId(req, NextResponse.json({ success: true })); } catch { - return NextResponse.json({ error: 'Failed' }, { status: 400 }); + return withRequestId(req, NextResponse.json({ error: 'Failed' }, { status: 400 })); } -} +}, 'PATCH /api/messages/[conversationId]'); diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts index 52086b2..4d7623e 100644 --- a/src/app/api/posts/[id]/repost/route.ts +++ b/src/app/api/posts/[id]/repost/route.ts @@ -35,7 +35,10 @@ export const POST = withErrorHandler(async (req: Request, ctx: unknown) => { return withRequestId(req, NextResponse.json({ success: true, repost }, { status: 201 })); } catch (err) { const message = err instanceof Error ? err.message : 'Repost failed'; - return withRequestId(req, NextResponse.json({ success: false, error: message }, { status: 400 })); + return withRequestId( + req, + NextResponse.json({ success: false, error: message }, { status: 400 }), + ); } }, 'POST /api/posts/[id]/repost'); @@ -49,6 +52,9 @@ export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => { await deleteRepost(session.user.id, id); return withRequestId(req, NextResponse.json({ success: true })); } catch { - return withRequestId(req, NextResponse.json({ success: false, error: 'Repost not found' }, { status: 404 })); + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Repost not found' }, { status: 404 }), + ); } }, 'DELETE /api/posts/[id]/repost'); diff --git a/src/app/api/users/online/route.ts b/src/app/api/users/online/route.ts new file mode 100644 index 0000000..755830b --- /dev/null +++ b/src/app/api/users/online/route.ts @@ -0,0 +1,25 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { listOnlineUsers, isUserOnline } from '@/backend/realtime/socket-server'; + +// presence — list every online user, or check a specific id with ?ids=a,b,c +export const GET = withErrorHandler(async (req: Request) => { + const { error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { searchParams } = new URL(req.url); + const ids = searchParams.get('ids'); + + if (ids) { + const requested = ids.split(',').slice(0, 100); + const status: Record = {}; + for (const id of requested) status[id] = isUserOnline(id); + return withRequestId(req, NextResponse.json({ success: true, status })); + } + + return withRequestId(req, NextResponse.json({ success: true, online: listOnlineUsers() })); +}, 'GET /api/users/online'); diff --git a/src/app/app/shops/[city]/page.tsx b/src/app/app/shops/[city]/page.tsx index dabb7a8..6330f42 100644 --- a/src/app/app/shops/[city]/page.tsx +++ b/src/app/app/shops/[city]/page.tsx @@ -73,9 +73,7 @@ export default async function CityShopsPage({ params, searchParams }: PageProps) dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
-

- Tattoo shops in {titleCase(cityName)} -

+

Tattoo shops in {titleCase(cityName)}

{meta.total} shops
diff --git a/src/backend/realtime/socket-server.ts b/src/backend/realtime/socket-server.ts index 7c5e681..64ef277 100644 --- a/src/backend/realtime/socket-server.ts +++ b/src/backend/realtime/socket-server.ts @@ -8,7 +8,9 @@ let io: Server | null = null; // event types export interface ServerToClientEvents { 'message:new': (data: MessageEvent) => void; - 'message:read': (data: { conversationId: string }) => void; + 'message:read': (data: { conversationId: string; byUserId: string }) => void; + 'message:typing': (data: { conversationId: string; userId: string }) => void; + 'message:typing-stop': (data: { conversationId: string; userId: string }) => void; 'notification:new': (data: NotificationEvent) => void; 'user:online': (data: { userId: string }) => void; 'user:offline': (data: { userId: string }) => void; @@ -18,6 +20,7 @@ export interface ClientToServerEvents { 'conversation:join': (conversationId: string) => void; 'conversation:leave': (conversationId: string) => void; 'message:typing': (conversationId: string) => void; + 'message:typing-stop': (conversationId: string) => void; } export interface MessageEvent { @@ -80,13 +83,20 @@ export function initSocketServer(httpServer: HttpServer): Server { socket.leave(`conversation:${conversationId}`); }); - // typing indicator + // typing indicators (start + stop) socket.on('message:typing', (conversationId: string) => { - socket.to(`conversation:${conversationId}`).emit('message:typing' as never, { - conversationId, - userId, - }); + socket + .to(`conversation:${conversationId}`) + .emit('message:typing' as never, { conversationId, userId }); }); + socket.on('message:typing-stop', (conversationId: string) => { + socket + .to(`conversation:${conversationId}`) + .emit('message:typing-stop' as never, { conversationId, userId }); + }); + + // presence: tell others this user is online + replay at connect + socket.broadcast.emit('user:online' as never, { userId }); // cleanup on disconnect socket.on('disconnect', () => { @@ -95,6 +105,7 @@ export function initSocketServer(httpServer: HttpServer): Server { sockets.delete(socket.id); if (sockets.size === 0) { userSockets.delete(userId); + socket.broadcast.emit('user:offline' as never, { userId }); } } }); @@ -118,7 +129,20 @@ export function emitNotification(userId: string, event: NotificationEvent) { io?.to(`user:${userId}`).emit('notification:new', event); } +// broadcast read receipt to the other participant(s) +export function emitConversationRead(conversationId: string, byUserId: string) { + io?.to(`conversation:${conversationId}`).emit('message:read', { + conversationId, + byUserId, + } as never); +} + // check if user is online export function isUserOnline(userId: string): boolean { return userSockets.has(userId); } + +// list currently-online user ids (presence) +export function listOnlineUsers(): string[] { + return [...userSockets.keys()]; +} diff --git a/src/backend/services/aftercare-service.ts b/src/backend/services/aftercare-service.ts index a124a8b..5364da8 100644 --- a/src/backend/services/aftercare-service.ts +++ b/src/backend/services/aftercare-service.ts @@ -32,9 +32,10 @@ export async function askAftercare(input: AftercareInput): Promise {/* eslint-disable-next-line @next/next/no-img-element */} - Generated coverup design + Generated coverup design )} diff --git a/src/utils/mentions.ts b/src/utils/mentions.ts index 8fa0d1b..fddda16 100644 --- a/src/utils/mentions.ts +++ b/src/utils/mentions.ts @@ -1,7 +1,8 @@ // extract @mentions from caption / comment content. -// rules: 3-30 chars, [a-zA-Z0-9_-], must follow whitespace or string start. +// rules: 3-30 chars, [a-zA-Z0-9_-], must follow whitespace or string start +// and end at a non-username char (so 31-char strings don't truncate-match). -const MENTION_RE = /(^|\s)@([a-zA-Z0-9_-]{3,30})/g; +const MENTION_RE = /(^|\s)@([a-zA-Z0-9_-]{3,30})(?![a-zA-Z0-9_-])/g; export function extractMentions(text: string): string[] { if (!text) return []; diff --git a/tests/services/collection-service.test.ts b/tests/services/collection-service.test.ts new file mode 100644 index 0000000..9996a9b --- /dev/null +++ b/tests/services/collection-service.test.ts @@ -0,0 +1,111 @@ +const collection = { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), +}; +const collectionItem = { + create: jest.fn(), + findUnique: jest.fn(), + delete: jest.fn(), +}; + +jest.mock('@/lib/prisma', () => ({ prisma: { collection, collectionItem } })); + +import { + addCollectionItem, + createCollection, + getCollectionBySlug, + getCollectionsForOwner, + removeCollectionItem, +} from '@/backend/services/collection-service'; + +beforeEach(() => jest.clearAllMocks()); + +describe('createCollection', () => { + it('defaults isPublic=true', async () => { + collection.create.mockResolvedValue({ id: 'c1' }); + await createCollection({ ownerId: 'u1', name: 'Sleeves', slug: 'sleeves' }); + expect(collection.create.mock.calls[0][0].data.isPublic).toBe(true); + }); + + it('respects isPublic=false override', async () => { + collection.create.mockResolvedValue({ id: 'c2' }); + await createCollection({ + ownerId: 'u1', + name: 'Private', + slug: 'private', + isPublic: false, + }); + expect(collection.create.mock.calls[0][0].data.isPublic).toBe(false); + }); +}); + +describe('getCollectionsForOwner', () => { + it('paginates + filters by owner', async () => { + collection.findMany.mockResolvedValue([]); + collection.count.mockResolvedValue(0); + await getCollectionsForOwner('u1', { page: 1, limit: 10, skip: 0 }); + expect(collection.findMany.mock.calls[0][0]).toMatchObject({ + where: { ownerId: 'u1' }, + orderBy: { createdAt: 'desc' }, + take: 10, + }); + }); +}); + +describe('getCollectionBySlug', () => { + it('uses compound (ownerId, slug) unique index', async () => { + await getCollectionBySlug('u1', 'flash'); + expect(collection.findUnique.mock.calls[0][0].where).toEqual({ + ownerId_slug: { ownerId: 'u1', slug: 'flash' }, + }); + }); +}); + +describe('addCollectionItem', () => { + it('throws when collection not found', async () => { + collection.findUnique.mockResolvedValue(null); + await expect( + addCollectionItem({ collectionId: 'c1', ownerId: 'u1', tattooId: 't1' }), + ).rejects.toThrow(/not found/i); + }); + + it('throws when caller is not owner', async () => { + collection.findUnique.mockResolvedValue({ ownerId: 'someone-else' }); + await expect( + addCollectionItem({ collectionId: 'c1', ownerId: 'u1', tattooId: 't1' }), + ).rejects.toThrow(/not found/i); + }); + + it('persists item when owner matches', async () => { + collection.findUnique.mockResolvedValue({ ownerId: 'u1' }); + collectionItem.create.mockResolvedValue({ id: 'i1' }); + await addCollectionItem({ collectionId: 'c1', ownerId: 'u1', postId: 'p1', note: 'cool' }); + expect(collectionItem.create.mock.calls[0][0].data).toMatchObject({ + collectionId: 'c1', + postId: 'p1', + note: 'cool', + }); + }); +}); + +describe('removeCollectionItem', () => { + it('throws when item missing', async () => { + collectionItem.findUnique.mockResolvedValue(null); + await expect(removeCollectionItem('i1', 'u1')).rejects.toThrow(/not found/i); + }); + + it('throws when caller is not owner of parent collection', async () => { + collectionItem.findUnique.mockResolvedValue({ + collection: { ownerId: 'someone-else' }, + }); + await expect(removeCollectionItem('i1', 'u1')).rejects.toThrow(/not found/i); + }); + + it('deletes when owner matches', async () => { + collectionItem.findUnique.mockResolvedValue({ collection: { ownerId: 'u1' } }); + await removeCollectionItem('i1', 'u1'); + expect(collectionItem.delete).toHaveBeenCalledWith({ where: { id: 'i1' } }); + }); +}); diff --git a/tests/services/price-estimator-service.test.ts b/tests/services/price-estimator-service.test.ts index 3cfa882..96d8118 100644 --- a/tests/services/price-estimator-service.test.ts +++ b/tests/services/price-estimator-service.test.ts @@ -19,7 +19,11 @@ describe('estimatePrice', () => { }); it('color and complexity multipliers compound', () => { - const grey = estimatePrice({ hourlyRate: baseRate, size: 'LARGE', colorType: 'BLACK_AND_GREY' }); + const grey = estimatePrice({ + hourlyRate: baseRate, + size: 'LARGE', + colorType: 'BLACK_AND_GREY', + }); const color = estimatePrice({ hourlyRate: baseRate, size: 'LARGE', colorType: 'COLOR' }); expect(color.midpoint).toBeGreaterThan(grey.midpoint); diff --git a/tests/services/repost-service.test.ts b/tests/services/repost-service.test.ts new file mode 100644 index 0000000..192c761 --- /dev/null +++ b/tests/services/repost-service.test.ts @@ -0,0 +1,58 @@ +const post = { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), +}; +const $transaction = jest.fn().mockImplementation(async (ops) => Promise.all(ops)); + +jest.mock('@/lib/prisma', () => ({ prisma: { post, $transaction } })); + +import { deleteRepost, repostPost } from '@/backend/services/repost-service'; + +beforeEach(() => jest.clearAllMocks()); + +describe('repostPost', () => { + it('throws when original post missing', async () => { + post.findUnique.mockResolvedValue(null); + await expect(repostPost('u1', 'p1')).rejects.toThrow(/not found/i); + }); + + it('blocks reposting your own post', async () => { + post.findUnique.mockResolvedValue({ id: 'p1', authorId: 'u1' }); + await expect(repostPost('u1', 'p1')).rejects.toThrow(/own post/i); + }); + + it('creates repost + increments parent count', async () => { + post.findUnique.mockResolvedValue({ id: 'p1', authorId: 'someone-else' }); + post.create.mockResolvedValue({ id: 'r1' }); + post.update.mockResolvedValue({}); + await repostPost('u1', 'p1', 'cool'); + expect($transaction).toHaveBeenCalled(); + const ops = $transaction.mock.calls[0][0]; + expect(ops).toHaveLength(2); + expect(post.create.mock.calls[0][0].data.repostOfId).toBe('p1'); + expect(post.create.mock.calls[0][0].data.caption).toBe('cool'); + }); +}); + +describe('deleteRepost', () => { + it('throws when not found / not owner / not a repost', async () => { + post.findUnique.mockResolvedValueOnce(null); + await expect(deleteRepost('u1', 'r1')).rejects.toThrow(/not found/i); + + post.findUnique.mockResolvedValueOnce({ authorId: 'someone-else', repostOfId: 'p1' }); + await expect(deleteRepost('u1', 'r1')).rejects.toThrow(/not found/i); + + post.findUnique.mockResolvedValueOnce({ authorId: 'u1', repostOfId: null }); + await expect(deleteRepost('u1', 'r1')).rejects.toThrow(/not found/i); + }); + + it('deletes + decrements parent count', async () => { + post.findUnique.mockResolvedValue({ authorId: 'u1', repostOfId: 'p1' }); + post.delete.mockResolvedValue({}); + post.update.mockResolvedValue({}); + await deleteRepost('u1', 'r1'); + expect($transaction).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/lib/__snapshots__/api-contract.test.ts.snap b/tests/unit/lib/__snapshots__/api-contract.test.ts.snap index cdc2fda..3dc6557 100644 --- a/tests/unit/lib/__snapshots__/api-contract.test.ts.snap +++ b/tests/unit/lib/__snapshots__/api-contract.test.ts.snap @@ -2,11 +2,15 @@ exports[`api contract — zod schema snapshots exposes the expected schema names: schema-names 1`] = ` [ + "addToCollectionSchema", + "aftercareSchema", "aiGenerateSchema", "availabilityBulkSchema", "availabilitySchema", "bookingRequestSchema", "bookingSchema", + "coverupSchema", + "createCollectionSchema", "createCommentSchema", "createConversationSchema", "createPostSchema", @@ -16,7 +20,9 @@ exports[`api contract — zod schema snapshots exposes the expected schema names "feedFilterSchema", "loginSchema", "paginationSchema", + "priceEstimateSchema", "registerSchema", + "repostSchema", "reviewSchema", "savePreviewSchema", "sendMessageSchema", @@ -26,9 +32,26 @@ exports[`api contract — zod schema snapshots exposes the expected schema names "updateBookingStatusSchema", "updateProfileSchema", "updateShopSchema", + "userSearchSchema", ] `; +exports[`api contract — zod schema snapshots shape: addToCollectionSchema: addToCollectionSchema 1`] = ` +{ + "note": "optional", + "postId": "optional", + "tattooId": "optional", +} +`; + +exports[`api contract — zod schema snapshots shape: aftercareSchema: aftercareSchema 1`] = ` +{ + "bookingId": "optional", + "daysSinceTattoo": "optional", + "question": "string", +} +`; + exports[`api contract — zod schema snapshots shape: aiGenerateSchema: aiGenerateSchema 1`] = ` { "colorType": "optional", @@ -77,6 +100,24 @@ exports[`api contract — zod schema snapshots shape: bookingSchema: bookingSche } `; +exports[`api contract — zod schema snapshots shape: coverupSchema: coverupSchema 1`] = ` +{ + "desiredStyle": "optional", + "desiredSubject": "optional", + "existingDescription": "string", + "placement": "optional", +} +`; + +exports[`api contract — zod schema snapshots shape: createCollectionSchema: createCollectionSchema 1`] = ` +{ + "description": "optional", + "isPublic": "default", + "name": "string", + "slug": "string", +} +`; + exports[`api contract — zod schema snapshots shape: createCommentSchema: createCommentSchema 1`] = ` { "content": "string", @@ -155,6 +196,18 @@ exports[`api contract — zod schema snapshots shape: paginationSchema: paginati } `; +exports[`api contract — zod schema snapshots shape: priceEstimateSchema: priceEstimateSchema 1`] = ` +{ + "artistId": "optional", + "colorType": "optional", + "complexity": "optional", + "hourlyRate": "optional", + "placement": "optional", + "size": "enum", + "styles": "optional", +} +`; + exports[`api contract — zod schema snapshots shape: registerSchema: registerSchema 1`] = ` { "email": "string", @@ -163,6 +216,13 @@ exports[`api contract — zod schema snapshots shape: registerSchema: registerSc } `; +exports[`api contract — zod schema snapshots shape: repostSchema: repostSchema 1`] = ` +{ + "caption": "optional", + "postId": "string", +} +`; + exports[`api contract — zod schema snapshots shape: reviewSchema: reviewSchema 1`] = ` { "artistId": "optional", @@ -254,3 +314,10 @@ exports[`api contract — zod schema snapshots shape: updateShopSchema: updateSh "website": "optional", } `; + +exports[`api contract — zod schema snapshots shape: userSearchSchema: userSearchSchema 1`] = ` +{ + "limit": "default", + "q": "string", +} +`; diff --git a/tests/unit/lib/feature-flags.test.ts b/tests/unit/lib/feature-flags.test.ts index 5ca8a66..d2b47ee 100644 --- a/tests/unit/lib/feature-flags.test.ts +++ b/tests/unit/lib/feature-flags.test.ts @@ -37,9 +37,12 @@ describe('feature-flags', () => { const all = flags.getFeatureFlags(); expect(Object.keys(all).sort()).toEqual( [ + 'AFTERCARE_AI_ENABLED', 'AI_GENERATION_ENABLED', 'AR_PREVIEW_ENABLED', 'BOOKING_ENABLED', + 'COLLECTIONS_ENABLED', + 'COVERUP_ENABLED', 'MESSAGING_ENABLED', 'PRICE_ESTIMATOR_ENABLED', 'SOCIAL_FEED_ENABLED', diff --git a/tests/unit/utils/ai-prompt-builder-coverup.test.ts b/tests/unit/utils/ai-prompt-builder-coverup.test.ts new file mode 100644 index 0000000..99c1d14 --- /dev/null +++ b/tests/unit/utils/ai-prompt-builder-coverup.test.ts @@ -0,0 +1,32 @@ +import { buildAftercareSystemPrompt, buildCoverupPrompt } from '@/utils/ai-prompt-builder'; + +describe('buildCoverupPrompt', () => { + it('always includes the mask-existing instruction', () => { + const out = buildCoverupPrompt({ existingDescription: 'faded tribal band' }); + expect(out).toContain('faded tribal band'); + expect(out).toContain('coverup design that fully obscures'); + expect(out).toContain('dark heavy ink'); + expect(out).toContain('white background'); + }); + + it('includes desired subject + style + placement when provided', () => { + const out = buildCoverupPrompt({ + existingDescription: 'old name', + desiredSubject: 'snake and dagger', + desiredStyle: 'JAPANESE', + placement: 'forearm', + }); + expect(out).toContain('new subject: snake and dagger'); + expect(out).toContain('japanese style'); + expect(out).toContain('forearm placement'); + }); +}); + +describe('buildAftercareSystemPrompt', () => { + it('contains medical-safety guard rails', () => { + const out = buildAftercareSystemPrompt(); + expect(out).toMatch(/never diagnose/i); + expect(out).toMatch(/never prescribe/i); + expect(out).toMatch(/contacting a doctor/i); + }); +}); diff --git a/tests/unit/utils/mentions.test.ts b/tests/unit/utils/mentions.test.ts new file mode 100644 index 0000000..f89c358 --- /dev/null +++ b/tests/unit/utils/mentions.test.ts @@ -0,0 +1,42 @@ +import { extractMentions, linkifyMentions } from '@/utils/mentions'; + +describe('extractMentions', () => { + it('returns empty array on empty input', () => { + expect(extractMentions('')).toEqual([]); + }); + + it('extracts a single mention', () => { + expect(extractMentions('hello @inkwell')).toEqual(['inkwell']); + }); + + it('lowercases + dedupes', () => { + expect(extractMentions('@Inkwell and @INKWELL and @sam')).toEqual(['inkwell', 'sam']); + }); + + it('rejects mentions inside other words (no match without leading whitespace)', () => { + expect(extractMentions('email me at foo@bar')).toEqual([]); + }); + + it('respects min/max length bounds (3-30)', () => { + expect(extractMentions('@ab @abc @' + 'a'.repeat(31))).toEqual(['abc']); + }); + + it('handles multiple mentions across lines', () => { + const text = '@one\n@two @three'; + expect(extractMentions(text).sort()).toEqual(['one', 'three', 'two']); + }); +}); + +describe('linkifyMentions', () => { + it('rewrites @user → markdown link', () => { + expect(linkifyMentions('hi @inkwell!')).toBe('hi [@inkwell](/app/profile/inkwell)!'); + }); + + it('preserves leading whitespace', () => { + expect(linkifyMentions('a @bee')).toContain(' [@bee]'); + }); + + it('leaves plain text untouched', () => { + expect(linkifyMentions('no mentions here')).toBe('no mentions here'); + }); +});