f494b1810e
- Ny notification_type 'proactive_tip' och consent_kind 'notifications'.
- Dubbelgrind: personalization + notifications samtycke måste vara granted.
- Mallbaserade pufftexter i 12 språk; ingen fri AI-text.
- Led med nyttan ('gurkan börjar bli slapp — recept du brukar gilla').
- Max 1 puff/hushåll/dag; ingen upprepning av samma vara+recept inom 7 dagar.
- UTC för all datummatte; createdAt sätts explicit från processorns now.
- Ny worker-jobbtyp SEND_PROACTIVE_TIPS.
- Integrationstester för dubbelgrind, revoke, frekvens, duplicate, UTC.
- Migration 0022 + worker test script kör db:test-setup.
379 lines
13 KiB
TypeScript
379 lines
13 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);
|
|
});
|
|
});
|