Files
Cibello-app/apps/api/test/scan-diff.test.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

227 lines
7.4 KiB
TypeScript

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<ReturnType<typeof buildServer>>;
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);
});
});