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"; import { computeBalance } from "@app/inventory-engine"; describe("quick reconciliation", () => { const testDb = createDatabase(process.env.TEST_DATABASE_URL!); const config = loadConfig(); let app: Awaited>; let token: string; let householdId: string; const email = "recon-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) { 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.inventoryItems) .where(eq(schema.inventoryItems.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.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: "Recon 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; // Skapa en vara att avstämma const locations = await testDb.db .select({ id: schema.storageLocations.id }) .from(schema.storageLocations) .where(eq(schema.storageLocations.householdId, householdId)); const [location] = locations; await app.inject({ method: "POST", url: "/v1/inventory/items", headers: { authorization: `Bearer ${token}` }, payload: { displayName: "Mjölk", quantity: 1, unit: "LITER", storageLocationId: location!.id, bestBeforeDate: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10), }, }); }); afterAll(async () => { await cleanup(); await closeDatabase(); await app.close(); }); it("starts reconciliation and returns candidates with reasons", async () => { const res = await app.inject({ method: "POST", url: "/v1/reconciliations/start", headers: { authorization: `Bearer ${token}` }, payload: {}, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { candidates: Array<{ itemId: string; reasons: unknown[]; suggestedAction: string }>; }; expect(body.candidates.length).toBeGreaterThan(0); expect(body.candidates[0]?.reasons.length).toBeGreaterThan(0); }); it("resolves 'exists' and marks item verified", 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; const res = await app.inject({ method: "POST", url: `/v1/reconciliations/items/${itemId}/resolve`, headers: { authorization: `Bearer ${token}` }, payload: { action: "exists", quantity: 0.5 }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { action: string; quantity: number; verifiedByUser: boolean; }; expect(body.action).toBe("exists"); 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); }); });