200 lines
7.1 KiB
TypeScript
200 lines
7.1 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";
|
|
import { computeBalance } from "@app/inventory-engine";
|
|
|
|
describe("quick reconciliation", () => {
|
|
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
|
const config = loadConfig();
|
|
let app: Awaited<ReturnType<typeof buildServer>>;
|
|
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);
|
|
});
|
|
});
|