import "./setup-env.js"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; import { eq, inArray } from "drizzle-orm"; import { buildServer } from "../src/server.js"; import { loadConfig } from "../src/config.js"; import { createDatabase, closeDatabase, schema } from "@app/database"; describe("scan confirmation → ai_corrections", () => { const testDb = createDatabase(process.env.TEST_DATABASE_URL!); const config = loadConfig(); let app: Awaited>; let token: string; let userId: string; let householdId: string; let locationId: string; const email = "scan-confirm-test@example.invalid"; async function cleanup() { const existing = await testDb.db .select({ id: schema.users.id }) .from(schema.users) .where(inArray(schema.users.email, [email])); for (const u of existing) { await testDb.db.delete(schema.aiCorrections).where(eq(schema.aiCorrections.userId, u.id)); await testDb.db.delete(schema.idempotencyKeys).where(eq(schema.idempotencyKeys.userId, u.id)); const memberships = await testDb.db .select({ householdId: schema.householdMembers.householdId }) .from(schema.householdMembers) .where(eq(schema.householdMembers.userId, u.id)); for (const m of memberships) { const items = await testDb.db .select({ id: schema.inventoryItems.id }) .from(schema.inventoryItems) .where(eq(schema.inventoryItems.householdId, m.householdId)); for (const it of items) { await testDb.db .delete(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, it.id)); await testDb.db .delete(schema.inventoryConflicts) .where(eq(schema.inventoryConflicts.inventoryItemId, it.id)); } await testDb.db .delete(schema.inventoryItems) .where(eq(schema.inventoryItems.householdId, m.householdId)); await testDb.db .delete(schema.inventoryConflicts) .where(eq(schema.inventoryConflicts.householdId, m.householdId)); await testDb.db .delete(schema.storageLocations) .where(eq(schema.storageLocations.householdId, m.householdId)); await testDb.db .delete(schema.householdMembers) .where(eq(schema.householdMembers.householdId, m.householdId)); await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId)); } await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id)); await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } } async function setConsent(imageTraining: boolean) { const kinds = ["personalization", "anonymized_improvement"] as const; for (const kind of kinds) { await testDb.db .insert(schema.userConsents) .values({ userId, kind, status: "granted" as const }) .onConflictDoUpdate({ target: [schema.userConsents.userId, schema.userConsents.kind], set: { status: "granted" as const }, }); } await testDb.db .insert(schema.userConsents) .values({ userId, kind: "image_training" as const, status: (imageTraining ? "granted" : "denied") as "granted" | "denied", }) .onConflictDoUpdate({ target: [schema.userConsents.userId, schema.userConsents.kind], set: { status: (imageTraining ? "granted" : "denied") as "granted" | "denied" }, }); } async function createScanJob() { const [job] = await testDb.db .insert(schema.scanJobs) .values({ userId, householdId, scanType: "fridge", jobType: "ANALYZE_FRIDGE_IMAGE", status: "awaiting_confirmation", s3Keys: ["fridge-scans/test-image.jpg"], result: { items: [ { tempId: "item-1", detectedName: "Mellanmjölk", canonicalIngredientId: "milk_1_5", brand: "Arla", estimatedQuantity: 1, unit: "LITER", confidence: 0.98, requiresConfirmation: false, }, ], }, modelVersion: "gemini-3.5-flash-lite", promptVersion: "gemini-fridge-v1", }) .returning(); return job!.id; } beforeAll(async () => { await cleanup(); app = await buildServer(config); await app.ready(); const res = await app.inject({ method: "POST", url: "/v1/auth/register", payload: { email, password: "Password123!", displayName: "Scan Confirm Test" }, }); const body = JSON.parse(res.body) as { accessToken: string }; token = body.accessToken; userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; const quick = await app.inject({ method: "POST", url: "/v1/onboarding/quick-start", headers: { authorization: `Bearer ${token}` }, payload: { goals: ["less_waste"], precisionMode: "simple" }, }); householdId = (JSON.parse(quick.body) as { householdId: string }).householdId; const [location] = await testDb.db .select({ id: schema.storageLocations.id }) .from(schema.storageLocations) .where(eq(schema.storageLocations.householdId, householdId)) .limit(1); locationId = location!.id; }); afterAll(async () => { await cleanup(); await closeDatabase(); await app.close(); }); it("accept action writes a positive row in ai_corrections", async () => { await setConsent(false); const scanJobId = await createScanJob(); const res = await app.inject({ method: "POST", url: `/v1/scans/${scanJobId}/confirm`, headers: { authorization: `Bearer ${token}` }, payload: { items: [ { tempId: "item-1", action: "accept", displayName: "Mellanmjölk", canonicalIngredientId: "milk_1_5", brand: "Arla", quantity: 1, unit: "LITER", storageLocationId: locationId, }, ], }, }); expect(res.statusCode).toBe(200); const corrections = await testDb.db .select() .from(schema.aiCorrections) .where(eq(schema.aiCorrections.scanJobId, scanJobId)); expect(corrections).toHaveLength(1); expect((corrections[0]!.userCorrection as Record).action).toBe("accept"); expect((corrections[0]!.proposal as Record).detectedName).toBe("Mellanmjölk"); expect( (corrections[0]!.userCorrection as Record>).corrected, ).toMatchObject({ displayName: "Mellanmjölk", quantity: 1, unit: "LITER", }); }); it("saves image reference when image_training consent is granted", async () => { await setConsent(true); const scanJobId = await createScanJob(); const res = await app.inject({ method: "POST", url: `/v1/scans/${scanJobId}/confirm`, headers: { authorization: `Bearer ${token}` }, payload: { items: [ { tempId: "item-1", action: "accept", displayName: "Mellanmjölk", quantity: 1, unit: "LITER", storageLocationId: locationId, }, ], }, }); expect(res.statusCode).toBe(200); const corrections = await testDb.db .select() .from(schema.aiCorrections) .where(eq(schema.aiCorrections.scanJobId, scanJobId)); expect(corrections[0]!.imageS3Key).toBe("fridge-scans/test-image.jpg"); expect((corrections[0]!.consentSnapshot as Record).image_training).toBe( "granted", ); }); it("does not save image reference when image_training consent is denied", async () => { await setConsent(false); const scanJobId = await createScanJob(); const res = await app.inject({ method: "POST", url: `/v1/scans/${scanJobId}/confirm`, headers: { authorization: `Bearer ${token}` }, payload: { items: [ { tempId: "item-1", action: "accept", displayName: "Mellanmjölk", quantity: 1, unit: "LITER", storageLocationId: locationId, }, ], }, }); expect(res.statusCode).toBe(200); const corrections = await testDb.db .select() .from(schema.aiCorrections) .where(eq(schema.aiCorrections.scanJobId, scanJobId)); expect(corrections[0]!.imageS3Key).toBeNull(); }); });