Files
Cibello-app/apps/worker/test/proactive-tips.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

401 lines
14 KiB
TypeScript

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);
});
});