feat(worker): S2 proaktiva puffar (expiring-ingredient)
- 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.
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user