Fas 2 steg 5: Scan-to-scan-diff + konfliktlösning, samt buggfix depleted-transaktion i reconciliation
This commit is contained in:
@@ -228,7 +228,17 @@ export async function reconciliationRoutes(app: FastifyInstance) {
|
||||
.returning();
|
||||
if (!updated) throw errors.internal("Kunde inte uppdatera varan.");
|
||||
|
||||
if (input.action === "exists" || quantityChange !== 0) {
|
||||
if (input.action === "depleted") {
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
inventoryItemId: params.itemId,
|
||||
householdId,
|
||||
actorUserId: req.userId,
|
||||
type: "correction",
|
||||
quantityDelta: -item.quantity,
|
||||
unit: item.unit,
|
||||
note: input.note,
|
||||
});
|
||||
} else if (input.action === "exists" || quantityChange !== 0) {
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
inventoryItemId: params.itemId,
|
||||
householdId,
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { schema } from "@app/database";
|
||||
import { diffScans, type ScanDiffRow, computeBalance } from "@app/inventory-engine";
|
||||
import { z } from "zod";
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js";
|
||||
import { scanDiffApplyInputSchema } from "@app/validation";
|
||||
import { inventoryConflictCreated, inventoryConflictResolved } from "@app/analytics";
|
||||
|
||||
/**
|
||||
* Scan-to-scan-diff + konfliktlösning (Fas 2 §5.5–5.6).
|
||||
*
|
||||
* - ALDRIG automatiska negativa transaktioner.
|
||||
* - "vanished" är förslag som kräver användarbekräftelse.
|
||||
* - Varje automatisk diff skapar en undo-post (samma transaktion kan ångras via negativ delta).
|
||||
*/
|
||||
export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
const auth = { preHandler: [app.authenticate] };
|
||||
|
||||
/** Jämför nuvarande lager mot ett nytt scan-resultat. */
|
||||
app.post("/v1/scans/:id/diff", auth, async (req) => {
|
||||
const params = z.object({ id: z.uuid() }).parse(req.params);
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
await requireMembership(app.db, householdId, req.userId);
|
||||
|
||||
const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1);
|
||||
if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte.");
|
||||
if (!job.result) throw errors.badRequest("Skanningen har inget resultat än.");
|
||||
|
||||
const observations = extractObservations(job.result as Record<string, unknown>);
|
||||
|
||||
const items = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, householdId));
|
||||
|
||||
const previousItems = items
|
||||
.filter((i) => i.quantity > 0 && !i.depletedAt)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
displayName: i.displayName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
storageLocationId: i.storageLocationId,
|
||||
bestBeforeDate: i.bestBeforeDate,
|
||||
useByDate: i.useByDate,
|
||||
}));
|
||||
|
||||
const diff = diffScans(previousItems, observations, new Date());
|
||||
|
||||
// Identifiera osäkra differ som konflikter (för review-vyn)
|
||||
const conflicts = diff.rows
|
||||
.filter((r) => r.kind === "vanished" || r.confidence < 0.75)
|
||||
.map((row) => ({
|
||||
kind: row.kind,
|
||||
displayName: row.displayName,
|
||||
previousItemId: row.previousItemId,
|
||||
proposedResolution: row.kind === "vanished" ? "depleted" : { quantity: row.newQuantity, locationId: row.newLocationId },
|
||||
}));
|
||||
|
||||
return {
|
||||
scanJobId: params.id,
|
||||
rows: diff.rows,
|
||||
pendingVanishedIds: diff.pendingVanishedIds,
|
||||
conflicts,
|
||||
modelVersion: job.modelVersion,
|
||||
promptVersion: job.promptVersion,
|
||||
};
|
||||
});
|
||||
|
||||
/** Applicera bekräftade diff-rader. Idempotency via idempotencyKeys. */
|
||||
app.post("/v1/scans/:id/diff/apply", auth, async (req) => {
|
||||
const params = z.object({ id: z.uuid() }).parse(req.params);
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
await requireMembership(app.db, householdId, req.userId);
|
||||
|
||||
const input = parse(scanDiffApplyInputSchema, req.body);
|
||||
|
||||
// Idempotency
|
||||
if (input.idempotencyKey) {
|
||||
const [existing] = await app.db
|
||||
.select()
|
||||
.from(schema.idempotencyKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.idempotencyKeys.userId, req.userId),
|
||||
eq(schema.idempotencyKeys.key, input.idempotencyKey),
|
||||
eq(schema.idempotencyKeys.endpoint, "POST /v1/scans/:id/diff/apply"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing?.responseBody) {
|
||||
return existing.responseBody as { appliedItemIds: string[] };
|
||||
}
|
||||
}
|
||||
|
||||
const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1);
|
||||
if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte.");
|
||||
|
||||
const appliedItemIds: string[] = [];
|
||||
|
||||
for (const row of input.rows) {
|
||||
if (row.kind === "new_item") {
|
||||
const locationId = row.newLocationId ?? (await defaultLocation(app, householdId, job.scanType));
|
||||
if (!locationId) continue;
|
||||
const [inv] = await app.db
|
||||
.insert(schema.inventoryItems)
|
||||
.values({
|
||||
householdId,
|
||||
displayName: row.displayName,
|
||||
quantity: row.newQuantity ?? 1,
|
||||
unit: row.unit as Unit,
|
||||
storageLocationId: locationId,
|
||||
source: scanSource(job.scanType),
|
||||
confidence: row.confidence,
|
||||
verifiedByUser: row.confidence >= 0.9,
|
||||
lastVerifiedAt: row.confidence >= 0.9 ? new Date() : null,
|
||||
modelVersion: job.modelVersion,
|
||||
promptVersion: job.promptVersion,
|
||||
})
|
||||
.returning();
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
householdId,
|
||||
inventoryItemId: inv!.id,
|
||||
type: "purchase",
|
||||
quantityDelta: row.newQuantity ?? 1,
|
||||
unit: row.unit as Unit,
|
||||
refType: "scan_diff",
|
||||
refId: params.id,
|
||||
actorUserId: req.userId,
|
||||
});
|
||||
appliedItemIds.push(inv!.id);
|
||||
} else if (row.kind === "quantity_changed" && row.previousItemId) {
|
||||
const [item] = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, row.previousItemId))
|
||||
.limit(1);
|
||||
if (!item) continue;
|
||||
const newQty = row.newQuantity ?? item.quantity;
|
||||
const delta = newQty - item.quantity;
|
||||
await app.db
|
||||
.update(schema.inventoryItems)
|
||||
.set({ quantity: newQty, updatedAt: new Date() })
|
||||
.where(eq(schema.inventoryItems.id, item.id));
|
||||
if (Math.abs(delta) > 1e-6) {
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
householdId,
|
||||
inventoryItemId: item.id,
|
||||
type: "correction",
|
||||
quantityDelta: delta,
|
||||
unit: item.unit,
|
||||
refType: "scan_diff",
|
||||
refId: params.id,
|
||||
actorUserId: req.userId,
|
||||
});
|
||||
}
|
||||
appliedItemIds.push(item.id);
|
||||
} else if (row.kind === "moved" && row.previousItemId) {
|
||||
const [item] = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, row.previousItemId))
|
||||
.limit(1);
|
||||
if (!item) continue;
|
||||
const newLocationId = row.newLocationId ?? item.storageLocationId;
|
||||
const newQty = row.newQuantity ?? item.quantity;
|
||||
const delta = newQty - item.quantity;
|
||||
await app.db
|
||||
.update(schema.inventoryItems)
|
||||
.set({ storageLocationId: newLocationId, quantity: newQty, updatedAt: new Date() })
|
||||
.where(eq(schema.inventoryItems.id, item.id));
|
||||
if (Math.abs(delta) > 1e-6) {
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
householdId,
|
||||
inventoryItemId: item.id,
|
||||
type: "correction",
|
||||
quantityDelta: delta,
|
||||
unit: item.unit,
|
||||
refType: "scan_diff",
|
||||
refId: params.id,
|
||||
actorUserId: req.userId,
|
||||
});
|
||||
}
|
||||
appliedItemIds.push(item.id);
|
||||
} else if (row.kind === "depleted" && row.previousItemId) {
|
||||
// Användaren har explicit bekräftat "vanished" → depleted
|
||||
const [item] = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, row.previousItemId))
|
||||
.limit(1);
|
||||
if (!item) continue;
|
||||
await app.db
|
||||
.update(schema.inventoryItems)
|
||||
.set({ quantity: 0, depletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(schema.inventoryItems.id, item.id));
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
householdId,
|
||||
inventoryItemId: item.id,
|
||||
type: "correction",
|
||||
quantityDelta: -item.quantity,
|
||||
unit: item.unit,
|
||||
refType: "scan_diff",
|
||||
refId: params.id,
|
||||
actorUserId: req.userId,
|
||||
});
|
||||
appliedItemIds.push(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Transaktionsinvariant: validera att saldot stämmer för alla berörda varor
|
||||
for (const itemId of appliedItemIds) {
|
||||
const [item] = await app.db
|
||||
.select({ quantity: schema.inventoryItems.quantity })
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
const txs = await app.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
if (Math.abs(balance.balance - (item?.quantity ?? 0)) > 1e-6) {
|
||||
throw errors.internal("Transaktionsinvarianten bröts efter scan-diff.");
|
||||
}
|
||||
}
|
||||
|
||||
const response = { appliedItemIds };
|
||||
|
||||
if (input.idempotencyKey) {
|
||||
await app.db
|
||||
.insert(schema.idempotencyKeys)
|
||||
.values({
|
||||
userId: req.userId,
|
||||
key: input.idempotencyKey,
|
||||
endpoint: "POST /v1/scans/:id/diff/apply",
|
||||
responseStatus: 200,
|
||||
responseBody: response as Record<string, unknown>,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
/** Lista öppna konflikter för hushållet. */
|
||||
app.get("/v1/inventory/conflicts", auth, async (req) => {
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
await requireMembership(app.db, householdId, req.userId);
|
||||
const conflicts = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryConflicts)
|
||||
.where(and(eq(schema.inventoryConflicts.householdId, householdId), eq(schema.inventoryConflicts.status, "open")))
|
||||
.orderBy(schema.inventoryConflicts.createdAt);
|
||||
return { conflicts };
|
||||
});
|
||||
|
||||
/** Lös en konflikt med valfritt resultat; skriver alltid vanliga transaktioner. */
|
||||
app.post("/v1/inventory/conflicts/:id/resolve", auth, async (req) => {
|
||||
const params = z.object({ id: z.uuid() }).parse(req.params);
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
await requireMembership(app.db, householdId, req.userId);
|
||||
|
||||
const body = z
|
||||
.object({
|
||||
resolution: z.enum(["source_a", "source_b", "manual"]),
|
||||
manualQuantity: z.number().min(0).optional(),
|
||||
manualLocationId: z.string().uuid().optional(),
|
||||
note: z.string().max(200).optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
|
||||
const [conflict] = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryConflicts)
|
||||
.where(eq(schema.inventoryConflicts.id, params.id))
|
||||
.limit(1);
|
||||
if (!conflict || conflict.householdId !== householdId) throw errors.notFound("Konflikten finns inte.");
|
||||
if (conflict.status !== "open") throw errors.badRequest("Konflikten är redan löst.");
|
||||
|
||||
const resolved = resolveConflictPayload(conflict, body);
|
||||
|
||||
if (resolved.quantity != null && conflict.inventoryItemId) {
|
||||
const [item] = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, conflict.inventoryItemId))
|
||||
.limit(1);
|
||||
if (item) {
|
||||
const delta = resolved.quantity - item.quantity;
|
||||
if (Math.abs(delta) > 1e-6 || resolved.status === "depleted") {
|
||||
const finalQty = resolved.status === "depleted" ? 0 : resolved.quantity;
|
||||
await app.db
|
||||
.update(schema.inventoryItems)
|
||||
.set({
|
||||
quantity: finalQty,
|
||||
storageLocationId: resolved.locationId ?? item.storageLocationId,
|
||||
depletedAt: resolved.status === "depleted" ? new Date() : item.depletedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.inventoryItems.id, item.id));
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
householdId,
|
||||
inventoryItemId: item.id,
|
||||
type: "correction",
|
||||
quantityDelta: finalQty - item.quantity,
|
||||
unit: item.unit,
|
||||
refType: "conflict",
|
||||
refId: conflict.id,
|
||||
actorUserId: req.userId,
|
||||
note: body.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await app.db
|
||||
.update(schema.inventoryConflicts)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedByUserId: req.userId,
|
||||
resolution: { ...resolved, note: body.note } as Record<string, unknown>,
|
||||
resolvedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.inventoryConflicts.id, params.id));
|
||||
|
||||
await trackProductAnalytics(
|
||||
app.db,
|
||||
req.userId,
|
||||
inventoryConflictResolved({
|
||||
householdId,
|
||||
properties: { conflictId: conflict.id, resolution: body.resolution },
|
||||
}),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
function extractObservations(result: Record<string, unknown> | unknown[]): Array<{
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
storageLocationId: string;
|
||||
bestBeforeDate?: string | null;
|
||||
useByDate?: string | null;
|
||||
observationConfidence: number;
|
||||
}> {
|
||||
const raw = Array.isArray(result) ? result : Array.isArray(result.items) ? (result.items as unknown[]) : [];
|
||||
return raw
|
||||
.map((r) => {
|
||||
const item = r as Record<string, unknown>;
|
||||
return {
|
||||
displayName: String(item.displayName ?? ""),
|
||||
quantity: Number(item.quantity ?? 1),
|
||||
unit: String(item.unit ?? "COUNT"),
|
||||
storageLocationId: String(item.storageLocationId ?? ""),
|
||||
bestBeforeDate: item.bestBeforeDate ? String(item.bestBeforeDate) : null,
|
||||
useByDate: item.useByDate ? String(item.useByDate) : null,
|
||||
observationConfidence: Number(item.confidence ?? item.observationConfidence ?? 0.8),
|
||||
};
|
||||
})
|
||||
.filter((r) => r.displayName && r.storageLocationId);
|
||||
}
|
||||
|
||||
function scanSource(scanType: string) {
|
||||
switch (scanType) {
|
||||
case "fridge":
|
||||
return "fridge_photo" as const;
|
||||
case "freezer":
|
||||
return "freezer_photo" as const;
|
||||
case "pantry":
|
||||
case "ingredients":
|
||||
return "pantry_photo" as const;
|
||||
default:
|
||||
return "label_photo" as const;
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultLocation(app: FastifyInstance, householdId: string, scanType: string) {
|
||||
const wanted =
|
||||
scanType === "freezer" ? "freezer" : scanType === "pantry" || scanType === "ingredients" ? "pantry" : "fridge";
|
||||
const [loc] = await app.db
|
||||
.select({ id: schema.storageLocations.id })
|
||||
.from(schema.storageLocations)
|
||||
.where(and(eq(schema.storageLocations.householdId, householdId), eq(schema.storageLocations.type, wanted)))
|
||||
.limit(1);
|
||||
return loc?.id ?? null;
|
||||
}
|
||||
|
||||
function resolveConflictPayload(
|
||||
conflict: typeof schema.inventoryConflicts.$inferSelect,
|
||||
body: { resolution: "source_a" | "source_b" | "manual"; manualQuantity?: number; manualLocationId?: string },
|
||||
): { quantity?: number; locationId?: string; status?: string } {
|
||||
const a = (conflict.payloadA ?? {}) as Record<string, unknown>;
|
||||
const b = (conflict.payloadB ?? {}) as Record<string, unknown>;
|
||||
if (body.resolution === "source_a") {
|
||||
return {
|
||||
quantity: a.quantity != null ? Number(a.quantity) : undefined,
|
||||
locationId: a.locationId ? String(a.locationId) : undefined,
|
||||
status: a.status ? String(a.status) : undefined,
|
||||
};
|
||||
}
|
||||
if (body.resolution === "source_b") {
|
||||
return {
|
||||
quantity: b.quantity != null ? Number(b.quantity) : undefined,
|
||||
locationId: b.locationId ? String(b.locationId) : undefined,
|
||||
status: b.status ? String(b.status) : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
quantity: body.manualQuantity,
|
||||
locationId: body.manualLocationId,
|
||||
status: body.manualQuantity === 0 ? "depleted" : "exists",
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { meRoutes } from "./routes/me.js";
|
||||
import { householdRoutes } from "./routes/households.js";
|
||||
import { inventoryRoutes } from "./routes/inventory.js";
|
||||
import { scanRoutes } from "./routes/scans.js";
|
||||
import { scanDiffRoutes } from "./routes/scan-diff.js";
|
||||
import { recipeRoutes } from "./routes/recipes.js";
|
||||
import { mealRoutes } from "./routes/meals.js";
|
||||
import { shoppingRoutes } from "./routes/shopping.js";
|
||||
@@ -77,6 +78,7 @@ export async function buildServer(config: AppConfig) {
|
||||
await app.register(householdRoutes);
|
||||
await app.register(inventoryRoutes);
|
||||
await app.register(scanRoutes);
|
||||
await app.register(scanDiffRoutes);
|
||||
await app.register(recipeRoutes);
|
||||
await app.register(mealRoutes);
|
||||
await app.register(shoppingRoutes);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { computeBalance } from "@app/inventory-engine";
|
||||
|
||||
describe("quick reconciliation", () => {
|
||||
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||
@@ -123,4 +124,56 @@ describe("quick reconciliation", () => {
|
||||
expect(body.quantity).toBe(0.5);
|
||||
expect(body.verifiedByUser).toBe(true);
|
||||
});
|
||||
|
||||
it("transaction invariant holds for exists/depleted/uncertain", async () => {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/reconciliations/start",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
const { candidates } = JSON.parse(start.body) as { candidates: Array<{ itemId: string }> };
|
||||
const itemId = candidates[0]!.itemId;
|
||||
|
||||
async function assertBalanceMatches(itemId: string) {
|
||||
const [item] = await testDb.db
|
||||
.select({ quantity: schema.inventoryItems.quantity })
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
const txs = await testDb.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs.map((tx) => ({ type: tx.type, quantityDelta: tx.quantityDelta, unit: tx.unit })));
|
||||
expect(balance.balance).toBeCloseTo(item!.quantity, 5);
|
||||
}
|
||||
|
||||
// exists med justering
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/reconciliations/items/${itemId}/resolve`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { action: "exists", quantity: 0.7 },
|
||||
});
|
||||
await assertBalanceMatches(itemId);
|
||||
|
||||
// depleted ska alltid skriva transaktion
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/reconciliations/items/${itemId}/resolve`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { action: "depleted" },
|
||||
});
|
||||
await assertBalanceMatches(itemId);
|
||||
|
||||
// uncertain med justering
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/reconciliations/items/${itemId}/resolve`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { action: "uncertain", quantity: 0.2 },
|
||||
});
|
||||
await assertBalanceMatches(itemId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user