Files
Cibello-app/apps/api/test/onboarding-memory.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

301 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("S5 — onboarding → minne + smaksignaler", () => {
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
let app: Awaited<ReturnType<typeof buildServer>>;
async function cleanupUser(email: string) {
const existing = await testDb.db
.select({ id: schema.users.id })
.from(schema.users)
.where(inArray(schema.users.email, [email, `deleted-${email}`]));
for (const u of existing) {
await testDb.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, u.id));
await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id));
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
await testDb.db
.delete(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, u.id));
await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id));
await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id));
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
}
}
async function registerUser(email: string) {
const res = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: { email, password: "Password123!", displayName: "S5 Test" },
});
const body = JSON.parse(res.body) as { accessToken: string };
const token = body.accessToken;
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
return { token, userId };
}
beforeAll(async () => {
app = await buildServer(config);
await app.ready();
});
afterAll(async () => {
await closeDatabase();
await app.close();
});
it("legacy POST /v1/me/onboarding skapar memory_items och taste_signals", async () => {
const email = "s5-legacy@example.invalid";
await cleanupUser(email);
const { token, userId } = await registerUser(email);
const res = await app.inject({
method: "POST",
url: "/v1/me/onboarding",
headers: { authorization: `Bearer ${token}` },
payload: {
precisionMode: "simple",
preferences: {
primaryGoal: "less_waste",
goals: ["less_waste", "cook_more"],
allergens: ["gluten", "milk"],
favoriteCuisines: ["italian", "thai"],
avoidIngredientIds: ["broccoli", "anchovy"],
spiceLevelMax: 2,
},
householdChoice: { kind: "skip" },
},
});
expect(res.statusCode).toBe(200);
const memory = await testDb.db
.select()
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
const memoryKeys = memory.map((m) => m.key).sort();
expect(memoryKeys).toEqual(
[
"goal:less_waste",
"goal:cook_more",
"goal:primary:less_waste",
"allergen:gluten",
"allergen:milk",
"favorite-cuisine:italian",
"favorite-cuisine:thai",
"avoid-ingredient:broccoli",
"avoid-ingredient:anchovy",
"spice-level-max",
].sort(),
);
for (const m of memory) {
expect(m.origin).toBe("user_stated");
expect(m.verifiedByUser).toBe(true);
expect(m.confidence).toBe(1);
}
const signals = await testDb.db
.select()
.from(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, userId));
const cuisineSignals = signals
.filter((s) => s.axis === "cuisine")
.sort((a, b) => (a.target ?? "").localeCompare(b.target ?? ""));
const avoidSignals = signals
.filter((s) => s.axis === "ingredient_avoid")
.sort((a, b) => (a.target ?? "").localeCompare(b.target ?? ""));
expect(cuisineSignals).toHaveLength(2);
expect(cuisineSignals[0]).toMatchObject({
target: "italian",
direction: 1,
origin: "user_stated",
});
expect(cuisineSignals[1]).toMatchObject({
target: "thai",
direction: 1,
origin: "user_stated",
});
expect(avoidSignals).toHaveLength(2);
expect(avoidSignals[0]).toMatchObject({
target: "anchovy",
direction: -1,
origin: "user_stated",
});
expect(avoidSignals[1]).toMatchObject({
target: "broccoli",
direction: -1,
origin: "user_stated",
});
await cleanupUser(email);
});
it("är idempotent upprepade anrop skapar inga dubletter", async () => {
const email = "s5-idempotent@example.invalid";
await cleanupUser(email);
const { token, userId } = await registerUser(email);
const payload = {
precisionMode: "simple",
preferences: {
primaryGoal: "more_protein",
goals: ["more_protein", "lower_cost"],
allergens: ["peanuts"],
favoriteCuisines: ["japanese"],
avoidIngredientIds: ["liver"],
spiceLevelMax: 3,
},
householdChoice: { kind: "skip" },
};
for (let i = 0; i < 2; i++) {
const res = await app.inject({
method: "POST",
url: "/v1/me/onboarding",
headers: { authorization: `Bearer ${token}` },
payload,
});
expect(res.statusCode).toBe(200);
}
const memoryCount = await testDb.db
.select({ id: schema.memoryItems.id })
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
expect(memoryCount).toHaveLength(7);
const signalCount = await testDb.db
.select({ id: schema.tasteSignals.id })
.from(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, userId));
expect(signalCount).toHaveLength(2);
await cleanupUser(email);
});
it("progressive onboarding complete-c skapar memory_items och taste_signals", async () => {
const email = "s5-progressive@example.invalid";
await cleanupUser(email);
const { token, userId } = await registerUser(email);
const stepA = await app.inject({
method: "POST",
url: "/v1/onboarding/quick-start",
headers: { authorization: `Bearer ${token}` },
payload: { goals: ["less_waste"], precisionMode: "simple" },
});
expect(stepA.statusCode).toBe(200);
const stepB = await app.inject({
method: "POST",
url: "/v1/onboarding/complete-b",
headers: { authorization: `Bearer ${token}` },
payload: {
precisionMode: "simple",
preferences: {
dietPattern: "vegetarian",
allergens: ["gluten"],
},
householdChoice: { kind: "skip" },
},
});
expect(stepB.statusCode).toBe(200);
const stepC = await app.inject({
method: "POST",
url: "/v1/onboarding/complete-c",
headers: { authorization: `Bearer ${token}` },
payload: {
precisionMode: "simple",
preferences: {
favoriteCuisines: ["italian"],
avoidIngredientIds: ["mushroom"],
spiceLevelMax: 2,
},
},
});
expect(stepC.statusCode).toBe(200);
const memory = await testDb.db
.select()
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
const keys = memory.map((m) => m.key).sort();
expect(keys).toContain("goal:less_waste");
expect(keys).toContain("goal:primary:less_waste");
expect(keys).toContain("allergen:gluten");
expect(keys).toContain("favorite-cuisine:italian");
expect(keys).toContain("avoid-ingredient:mushroom");
expect(keys).toContain("spice-level-max");
const signals = await testDb.db
.select()
.from(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, userId));
expect(signals).toHaveLength(2);
expect(
signals.some((s) => s.axis === "cuisine" && s.target === "italian" && s.direction === 1),
).toBe(true);
expect(
signals.some(
(s) => s.axis === "ingredient_avoid" && s.target === "mushroom" && s.direction === -1,
),
).toBe(true);
await cleanupUser(email);
});
it("DELETE /v1/me tömmer onboarding-skapade memory_items och taste_signals", async () => {
const email = "s5-gdpr@example.invalid";
await cleanupUser(email);
const { token, userId } = await registerUser(email);
await app.inject({
method: "POST",
url: "/v1/me/onboarding",
headers: { authorization: `Bearer ${token}` },
payload: {
precisionMode: "simple",
preferences: {
primaryGoal: "less_waste",
goals: ["less_waste"],
allergens: ["gluten"],
favoriteCuisines: ["italian"],
avoidIngredientIds: ["broccoli"],
spiceLevelMax: 2,
},
householdChoice: { kind: "skip" },
},
});
const deleteRes = await app.inject({
method: "DELETE",
url: "/v1/me",
headers: { authorization: `Bearer ${token}` },
});
expect(deleteRes.statusCode).toBe(200);
const remainingMemory = await testDb.db
.select({ id: schema.memoryItems.id })
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
expect(remainingMemory).toHaveLength(0);
const remainingSignals = await testDb.db
.select({ id: schema.tasteSignals.id })
.from(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, userId));
expect(remainingSignals).toHaveLength(0);
await cleanupUser(email);
});
});