import "./setup-env.js"; import { describe, it, expect, beforeEach, afterAll } from "vitest"; import { and, eq, gte, lt } from "drizzle-orm"; import { createDatabase, closeDatabase, schema } from "@app/database"; import { processProactiveTips } from "../src/processors/maintenance.js"; import type { WorkerContext } from "../src/context.js"; const FIXED_UTC = new Date(Date.UTC(2026, 7, 10, 8, 0, 0)); // 2026-08-10 08:00 UTC describe.sequential("SEND_PROACTIVE_TIPS", () => { const testDb = createDatabase(process.env.TEST_DATABASE_URL!); const ctx: WorkerContext = { db: testDb.db } as never; const emailBase = "proactive-tips-test"; async function cleanup() { const emails = [`${emailBase}-owner@example.invalid`, `${emailBase}-member@example.invalid`]; const users = await testDb.db .select({ id: schema.users.id }) .from(schema.users) .where(eq(schema.users.email, emails[0])); const userIds = users.map((u) => u.id); for (const userId of userIds) { await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, userId)); await testDb.db .delete(schema.householdMembers) .where(eq(schema.householdMembers.userId, userId)); await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, userId)); } const households = await testDb.db .select({ id: schema.households.id }) .from(schema.households) .where(eq(schema.households.name, "Proactive Tips Test")); for (const h of households) { await testDb.db .delete(schema.inventoryItems) .where(eq(schema.inventoryItems.householdId, h.id)); 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)); } for (const email of emails) { await testDb.db.delete(schema.users).where(eq(schema.users.email, email)); } // Clean up test recipes/ingredients if exists. await testDb.db .delete(schema.recipeIngredients) .where(eq(schema.recipeIngredients.recipeId, "00000000-0000-0000-0000-000000000abc")); await testDb.db .delete(schema.recipeIngredients) .where(eq(schema.recipeIngredients.recipeId, "00000000-0000-0000-0000-000000000def")); await testDb.db .delete(schema.recipes) .where(eq(schema.recipes.id, "00000000-0000-0000-0000-000000000abc")); await testDb.db .delete(schema.recipes) .where(eq(schema.recipes.id, "00000000-0000-0000-0000-000000000def")); await testDb.db .delete(schema.canonicalIngredients) .where(eq(schema.canonicalIngredients.id, "test_cucumber_001")); await testDb.db .delete(schema.canonicalIngredients) .where(eq(schema.canonicalIngredients.id, "test_tomato_001")); } async function setup() { const [owner] = await testDb.db .insert(schema.users) .values({ email: `${emailBase}-owner@example.invalid`, passwordHash: "not-used", displayName: "Owner", }) .returning(); const [member] = await testDb.db .insert(schema.users) .values({ email: `${emailBase}-member@example.invalid`, passwordHash: "not-used", displayName: "Member", }) .returning(); const [household] = await testDb.db .insert(schema.households) .values({ name: "Proactive Tips Test", size: 2, locale: "sv-SE", inviteCode: "PROACTIVETEST", }) .returning(); await testDb.db.insert(schema.householdMembers).values([ { householdId: household!.id, userId: owner!.id, role: "owner" }, { householdId: household!.id, userId: member!.id, role: "member" }, ]); const [pantry] = await testDb.db .insert(schema.storageLocations) .values({ householdId: household!.id, type: "pantry", name: "Skafferi" }) .returning(); await testDb.db.insert(schema.canonicalIngredients).values({ id: "test_cucumber_001", nameSv: "Gurka", nameEn: "Cucumber", category: "vegetable", defaultUnit: "COUNT", allergens: [], containsGluten: false, containsLactose: false, isPork: false, isBeef: false, isAlcohol: false, densityGPerMl: 0.95, nutritionPer100: { kcal: 15, proteinG: 0.7, carbsG: 3.6, fatG: 0.1 }, nutritionProvenance: { source: "test", confidence: "high" }, shelfLifeGuidance: { pantryDays: 7, fridgeDays: 10 }, }); await testDb.db.insert(schema.recipes).values({ id: "00000000-0000-0000-0000-000000000abc", slug: "test-gurkraita", titleSv: "Gurkraita", descriptionSv: "Fräsch gurkraita.", cuisine: "international", mealTypes: ["dinner"], tags: ["quick", "vegetarian"], methods: ["stovetop"], difficulty: "easy", sourceType: "own_editorial", verificationStatus: "verified", status: "published", portions: 4, prepTimeMinutes: 5, cookTimeMinutes: 0, totalTimeMinutes: 5, nutritionPerPortion: { kcal: 120, proteinG: 4, carbsG: 8, fatG: 8 }, dna: { cuisine: "international", vegetables: ["cucumber"], flavorProfile: ["fresh"], spiceLevel: 0, method: "stovetop", timeMinutes: 5, calories: 120, proteinGrams: 4, }, }); await testDb.db.insert(schema.recipeIngredients).values({ recipeId: "00000000-0000-0000-0000-000000000abc", canonicalIngredientId: "test_cucumber_001", displayNameSv: "Gurka", quantity: 1, unit: "COUNT", optional: false, }); return { ownerId: owner!.id, memberId: member!.id, householdId: household!.id, pantryId: pantry!.id, }; } async function addExpiringCucumber(householdId: string, pantryId: string, bestBefore: string) { return testDb.db.insert(schema.inventoryItems).values({ householdId, storageLocationId: pantryId, canonicalIngredientId: "test_cucumber_001", displayName: "Gurka", quantity: 2, unit: "COUNT", bestBeforeDate: bestBefore, confidence: 1, verifiedByUser: true, trustState: "fresh", source: "manual_search", }); } async function grantConsents(userId: string) { await testDb.db.insert(schema.userConsents).values([ { userId, kind: "personalization", status: "granted" }, { userId, kind: "notifications", status: "granted" }, ]); } beforeEach(async () => { await cleanup(); }); afterAll(async () => { await cleanup(); await closeDatabase(); }); it("skapar en proaktiv puff när båda samtyckena finns och en vara går ut", async () => { const { ownerId, householdId, pantryId } = await setup(); await grantConsents(ownerId); await addExpiringCucumber(householdId, pantryId, "2026-08-11"); // 1 dag kvar const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); expect(count).toBe(1); const notif = await testDb.db .select() .from(schema.notifications) .where(eq(schema.notifications.userId, ownerId)); expect(notif).toHaveLength(1); expect(notif[0]!.type).toBe("proactive_tip"); expect(notif[0]!.titleSv).toContain("Gurka"); expect(notif[0]!.bodySv).toContain("Gurka"); expect(notif[0]!.templateKey).toBe("notification.proactive_tip.expiring_ingredient"); }); it("skapar INGEN puff utan notifications-samtycke", async () => { const { ownerId, householdId, pantryId } = await setup(); await testDb.db.insert(schema.userConsents).values({ userId: ownerId, kind: "personalization", status: "granted", }); await addExpiringCucumber(householdId, pantryId, "2026-08-11"); const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); expect(count).toBe(0); }); it("skapar INGEN puff utan personalization-samtycke", async () => { const { ownerId, householdId, pantryId } = await setup(); await testDb.db.insert(schema.userConsents).values({ userId: ownerId, kind: "notifications", status: "granted", }); await addExpiringCucumber(householdId, pantryId, "2026-08-11"); const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); expect(count).toBe(0); }); it("stoppas vid revoke av notifications-samtycke", async () => { const { ownerId, householdId, pantryId } = await setup(); await grantConsents(ownerId); await addExpiringCucumber(householdId, pantryId, "2026-08-11"); // Först skapas puffen. let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); expect(count).toBe(1); // Revoke. await testDb.db .update(schema.userConsents) .set({ status: "revoked" }) .where( and(eq(schema.userConsents.userId, ownerId), eq(schema.userConsents.kind, "notifications")), ); // Rensa notisen för att simulera ny dag. await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, ownerId)); count = await processProactiveTips(ctx, { nowUtc: new Date(FIXED_UTC.getTime() + 24 * 60 * 60 * 1000), }); expect(count).toBe(0); }); it("max 1 puff per hushåll per dag", async () => { const { ownerId, memberId, householdId, pantryId } = await setup(); await grantConsents(ownerId); await grantConsents(memberId); await addExpiringCucumber(householdId, pantryId, "2026-08-11"); const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); expect(count).toBe(1); const notifs = await testDb.db .select() .from(schema.notifications) .where(eq(schema.notifications.type, "proactive_tip")); expect(notifs).toHaveLength(1); }); it("ingen upprepning av samma vara+recept inom 7 dagar", async () => { const { ownerId, householdId, pantryId } = await setup(); await grantConsents(ownerId); await addExpiringCucumber(householdId, pantryId, "2026-08-11"); let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); expect(count).toBe(1); // Efter 7 dagar är dagsgränsen passerad, men 7-dagars duplicate-gräns stoppar samma vara+recept. count = await processProactiveTips(ctx, { nowUtc: new Date(Date.UTC(2026, 7, 17, 8, 0, 0)), }); expect(count).toBe(0); }); it("UTC-gränser: ny dag kl 00:00 UTC tillåter ny puff för ny vara", async () => { const { ownerId, householdId, pantryId } = await setup(); await grantConsents(ownerId); await addExpiringCucumber(householdId, pantryId, "2026-08-11"); let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); expect(count).toBe(1); // Lägg till en andra utgångsnära vara för att testa att dagsgränsen är UTC. await testDb.db.insert(schema.canonicalIngredients).values({ id: "test_tomato_001", nameSv: "Tomat", nameEn: "Tomato", category: "vegetable", defaultUnit: "COUNT", allergens: [], containsGluten: false, containsLactose: false, isPork: false, isBeef: false, isAlcohol: false, densityGPerMl: 0.6, nutritionPer100: { kcal: 18, proteinG: 0.9, carbsG: 3.9, fatG: 0.2 }, nutritionProvenance: { source: "test", confidence: "high" }, shelfLifeGuidance: { pantryDays: 5, fridgeDays: 8 }, }); await testDb.db.insert(schema.recipes).values({ id: "00000000-0000-0000-0000-000000000def", slug: "test-tomatsallad", titleSv: "Tomatsallad", descriptionSv: "Fräsch tomatsallad.", cuisine: "international", mealTypes: ["dinner"], tags: ["quick", "vegetarian"], methods: ["stovetop"], difficulty: "easy", sourceType: "own_editorial", verificationStatus: "verified", status: "published", portions: 4, prepTimeMinutes: 5, cookTimeMinutes: 0, totalTimeMinutes: 5, nutritionPerPortion: { kcal: 120, proteinG: 4, carbsG: 8, fatG: 8 }, dna: { cuisine: "international", vegetables: ["tomato"], flavorProfile: ["fresh"], spiceLevel: 0, method: "stovetop", timeMinutes: 5, calories: 120, proteinGrams: 4, }, }); await testDb.db.insert(schema.recipeIngredients).values({ recipeId: "00000000-0000-0000-0000-000000000def", canonicalIngredientId: "test_tomato_001", displayNameSv: "Tomat", quantity: 1, unit: "COUNT", optional: false, }); await testDb.db.insert(schema.inventoryItems).values({ householdId, storageLocationId: pantryId, canonicalIngredientId: "test_tomato_001", displayName: "Tomat", quantity: 2, unit: "COUNT", bestBeforeDate: "2026-08-11", confidence: 1, verifiedByUser: true, trustState: "fresh", source: "manual_search", }); count = await processProactiveTips(ctx, { nowUtc: new Date(Date.UTC(2026, 7, 11, 0, 0, 0)), }); expect(count).toBe(1); }); it("texten leder med nyttan och innehåller ingen förbjuden copy", async () => { const { ownerId, householdId, pantryId } = await setup(); await grantConsents(ownerId); await addExpiringCucumber(householdId, pantryId, "2026-08-11"); await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); const [notif] = await testDb.db .select() .from(schema.notifications) .where(eq(schema.notifications.userId, ownerId)); expect(notif!.bodySv).toMatch(/slapp|rädda|börjar bli/i); expect(notif!.bodySv).not.toMatch(/skam|borde äta mindre|obalanserad|dålig vana/i); }); });