import "./setup-env.js"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; import { and, eq, inArray } from "drizzle-orm"; import { buildServer } from "../src/server.js"; import { loadConfig } from "../src/config.js"; import { createDatabase, closeDatabase, schema } from "@app/database"; const testDb = createDatabase(process.env.TEST_DATABASE_URL!); const config = loadConfig(); const emails = { patch: "memory-patch@example.invalid", consent: "memory-consent@example.invalid", privacyA: "memory-privacy-a@example.invalid", privacyB: "memory-privacy-b@example.invalid", }; describe("/v1/me/memory", () => { let app: Awaited>; beforeAll(async () => { app = await buildServer(config); await app.ready(); await cleanupAll(); }); afterAll(async () => { await cleanupAll(); await closeDatabase(); await app.close(); }); async function cleanupAll() { const allEmails = Object.values(emails); const existing = await testDb.db .select({ id: schema.users.id }) .from(schema.users) .where(inArray(schema.users.email, allEmails)); 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.userConsents).where(eq(schema.userConsents.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); await testDb.db .delete(schema.householdMembers) .where(eq(schema.householdMembers.userId, u.id)); const ownedHouseholds = await testDb.db .select({ id: schema.households.id }) .from(schema.households) .innerJoin( schema.householdMembers, eq(schema.householdMembers.householdId, schema.households.id), ) .where( and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")), ); for (const h of ownedHouseholds) { await testDb.db .delete(schema.storageLocations) .where(eq(schema.storageLocations.householdId, h.id)); await testDb.db.delete(schema.households).where(eq(schema.households.id, h.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: "Memory 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; await app.inject({ method: "POST", url: "/v1/onboarding/quick-start", headers: { authorization: `Bearer ${token}` }, payload: { goals: ["cook_more"], precisionMode: "simple" }, }); return { token, userId }; } it("PATCH /v1/me/memory/:id ändrar origin till user_stated", async () => { const { token, userId } = await registerUser(emails.patch); const [item] = await testDb.db .insert(schema.memoryItems) .values({ userId, kind: "structured_fact", key: "likes-pasta", summarySv: "Gillar pasta", origin: "ai_inferred", confidence: 0.5, }) .returning(); const patchRes = await app.inject({ method: "PATCH", url: `/v1/me/memory/${item!.id}`, headers: { authorization: `Bearer ${token}` }, payload: { summarySv: "Gillar verkligen pasta" }, }); expect(patchRes.statusCode).toBe(200); const body = JSON.parse(patchRes.body) as { origin: string; confidence: number; verifiedByUser: boolean; }; expect(body.origin).toBe("user_stated"); expect(body.confidence).toBe(1); expect(body.verifiedByUser).toBe(true); }); it("GET /v1/me/memory/:id/impact returnerar tom lista utan personalization-samtycke", async () => { const { token, userId } = await registerUser(emails.consent); const [item] = await testDb.db .insert(schema.memoryItems) .values({ userId, kind: "structured_fact", key: "favorite-cuisine-thai", summarySv: "Gillar thaimat", value: { favoriteCuisine: "thai" }, origin: "user_stated", confidence: 1, }) .returning(); const res = await app.inject({ method: "GET", url: `/v1/me/memory/${item!.id}/impact`, headers: { authorization: `Bearer ${token}` }, }); expect(res.statusCode).toBe(403); const body = JSON.parse(res.body) as { error: { code: string } }; expect(body.error.code).toBe("PERSONALIZATION_CONSENT_REQUIRED"); }); it("impact för användare A returnerar aldrig användare B:s data", async () => { const userA = await registerUser(emails.privacyA); const userB = await registerUser(emails.privacyB); await testDb.db.insert(schema.userConsents).values([ { userId: userA.userId, kind: "personalization", status: "granted" }, { userId: userB.userId, kind: "personalization", status: "granted" }, ]); const [itemA] = await testDb.db .insert(schema.memoryItems) .values({ userId: userA.userId, kind: "structured_fact", key: "favorite-cuisine-thai", summarySv: "Gillar thaimat", value: { favoriteCuisine: "thai" }, origin: "user_stated", confidence: 1, }) .returning(); await testDb.db.insert(schema.memoryItems).values({ userId: userB.userId, kind: "structured_fact", key: "favorite-cuisine-italian", summarySv: "Gillar italienskt", value: { favoriteCuisine: "italian" }, origin: "user_stated", confidence: 1, }); const res = await app.inject({ method: "GET", url: `/v1/me/memory/${itemA!.id}/impact`, headers: { authorization: `Bearer ${userA.token}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { memoryItemId: string; impacted: Array<{ whySv: string }>; }; expect(body.memoryItemId).toBe(itemA!.id); // Varje varat whySv ska vara från befintliga mallar och får inte avslöja B:s data. for (const rec of body.impacted) { expect(rec.whySv).not.toContain("italian"); expect(rec.whySv).not.toContain("italienskt"); } }); }); describe("memory i18n parity", () => { it("alla 12 språk har origin, paused och guess utan saknade/dubbletter", async () => { // Dynamisk import för att slippa cirkulärt beroende i testsetup. const { buildMemoryOverview } = await import("@app/memory-client"); const { SUPPORTED_LANGUAGE_TAGS } = await import("@app/shared-types"); const item = { id: "m1", userId: "u1", kind: "structured_fact" as const, key: "k1", summarySv: "Sammanfattning", value: { favoriteCuisine: "thai" }, origin: "ai_inferred" as const, confidence: 0.5, verifiedByUser: false, paused: true, createdAt: "2026-08-01T00:00:00.000Z", updatedAt: "2026-08-01T00:00:00.000Z", }; for (const tag of SUPPORTED_LANGUAGE_TAGS) { const overview = buildMemoryOverview([item], tag); const rendered = overview.sections[0]!.items[0]!; expect(rendered.originLabel.length).toBeGreaterThan(0); expect(rendered.pausedLabel).toBeTruthy(); expect(rendered.guessLabel).toBeTruthy(); } }); }); describe("DELETE /v1/me/memory GDPR-regression", () => { let app: Awaited>; beforeAll(async () => { app = await buildServer(config); await app.ready(); }); afterAll(async () => { await app.close(); }); async function cleanup(email: string) { const existing = await testDb.db .select({ id: schema.users.id }) .from(schema.users) .where(eq(schema.users.email, 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.userConsents).where(eq(schema.userConsents.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); await testDb.db .delete(schema.householdMembers) .where(eq(schema.householdMembers.userId, u.id)); const ownedHouseholds = await testDb.db .select({ id: schema.households.id }) .from(schema.households) .innerJoin( schema.householdMembers, eq(schema.householdMembers.householdId, schema.households.id), ) .where( and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")), ); for (const h of ownedHouseholds) { await testDb.db .delete(schema.storageLocations) .where(eq(schema.storageLocations.householdId, h.id)); await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id)); } await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } } async function registerUser(email: string) { await cleanup(email); const res = await app.inject({ method: "POST", url: "/v1/auth/register", payload: { email, password: "Password123!", displayName: "Memory GDPR 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; await app.inject({ method: "POST", url: "/v1/onboarding/quick-start", headers: { authorization: `Bearer ${token}` }, payload: { goals: ["cook_more"], precisionMode: "simple" }, }); return { token, userId }; } it("tömmer memoryItems och tasteSignals", async () => { const email = "memory-delete-all@example.invalid"; const { token, userId } = await registerUser(email); await testDb.db.insert(schema.memoryItems).values({ userId, kind: "structured_fact", key: "likes-pasta", summarySv: "Gillar pasta", value: { favoriteCuisine: "italian" }, origin: "user_stated", confidence: 1, }); await testDb.db.insert(schema.tasteSignals).values({ userId, axis: "spice", direction: 1, strength: 0.8, origin: "user_stated", }); const res = await app.inject({ method: "DELETE", url: "/v1/me/memory", headers: { authorization: `Bearer ${token}` }, }); expect(res.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 cleanup(email); }); });