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-to-scan-diff", () => { const testDb = createDatabase(process.env.TEST_DATABASE_URL!); const config = loadConfig(); let app: Awaited>; let token: string; let householdId: string; let scanJobId: string; const email = "scan-diff-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.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.userPreferences).where(eq(schema.userPreferences.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.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 Diff Test" }, }); const body = JSON.parse(res.body) as { accessToken: string }; token = body.accessToken; 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); // Skapa två varor att diffa mot await app.inject({ method: "POST", url: "/v1/inventory/items", headers: { authorization: `Bearer ${token}` }, payload: { displayName: "Mjölk", quantity: 1, unit: "LITER", storageLocationId: location!.id, }, }); await app.inject({ method: "POST", url: "/v1/inventory/items", headers: { authorization: `Bearer ${token}` }, payload: { displayName: "Yoghurt", quantity: 2, unit: "COUNT", storageLocationId: location!.id, }, }); // Skapa ett färdigt scan-jobb med resultat const [job] = await testDb.db .insert(schema.scanJobs) .values({ userId: (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub, householdId, scanType: "fridge", jobType: "ANALYZE_FRIDGE_IMAGE", status: "completed", result: [ { displayName: "Mjölk", quantity: 0.5, unit: "LITER", storageLocationId: location!.id, confidence: 0.9 }, { displayName: "Ost", quantity: 1, unit: "COUNT", storageLocationId: location!.id, confidence: 0.9 }, ], modelVersion: "v1", promptVersion: "p1", }) .returning(); scanJobId = job!.id; }); afterAll(async () => { await cleanup(); await closeDatabase(); await app.close(); }); it("returns diff rows with model/prompt versions", async () => { const res = await app.inject({ method: "POST", url: `/v1/scans/${scanJobId}/diff`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { rows: Array<{ kind: string; displayName: string }>; modelVersion: string; promptVersion: string; }; expect(body.modelVersion).toBe("v1"); expect(body.promptVersion).toBe("p1"); const kinds = body.rows.map((r) => r.kind); expect(kinds).toContain("quantity_changed"); expect(kinds).toContain("new_item"); expect(kinds).toContain("vanished"); }); it("applies accepted diff rows idempotently", async () => { const diff = await app.inject({ method: "POST", url: `/v1/scans/${scanJobId}/diff`, headers: { authorization: `Bearer ${token}` }, payload: {}, }); const { rows } = JSON.parse(diff.body) as { rows: Array<{ kind: string; itemId?: string; previousItemId?: string; displayName: string; newQuantity?: number; newLocationId?: string; confidence: number }> }; const changed = rows.find((r) => r.kind === "quantity_changed")!; const apply = await app.inject({ method: "POST", url: `/v1/scans/${scanJobId}/diff/apply`, headers: { authorization: `Bearer ${token}` }, payload: { idempotencyKey: "diff-apply-1", rows: [ { kind: "quantity_changed", previousItemId: changed.previousItemId, displayName: changed.displayName, unit: "LITER", newQuantity: changed.newQuantity, confidence: changed.confidence, }, ], }, }); expect(apply.statusCode).toBe(200); const applied = JSON.parse(apply.body) as { appliedItemIds: string[] }; expect(applied.appliedItemIds.length).toBeGreaterThan(0); // Idempotency: samma nyckel ska ge samma svar const apply2 = await app.inject({ method: "POST", url: `/v1/scans/${scanJobId}/diff/apply`, headers: { authorization: `Bearer ${token}` }, payload: { idempotencyKey: "diff-apply-1", rows: [], }, }); expect(apply2.statusCode).toBe(200); expect(JSON.parse(apply2.body)).toEqual(applied); }); });