feat(recommendation-engine): S1 personalisering för 'Vad ska vi äta?'
- Mallbaserad proveniens i 12 språk (inga fria AI-texter i rekommendationer). - memoryFit, tasteFit, cookingAssumptionFit endast vid personalization-samtycke. - Hård grind i API:et: läser memory_items/taste_signals/cooking_assumption_profiles endast när userConsents.personalization = granted. - NON_PERSONALIZED_WEIGHTS bevarar existerande beteende vid avsaknad av samtycke. - Positiv, icke-restriktiv näringscopy (R7). - Deterministisk scoring + enhetstester för S1. - Integrationstest som verifierar provenans-gate med/utan samtycke.
This commit is contained in:
@@ -113,6 +113,72 @@ describe("what-to-eat without household", () => {
|
||||
expect(prefs?.primaryGoal).toBe("cook_more");
|
||||
});
|
||||
|
||||
it("personalization is gated by consent: no provenance without granted consent", async () => {
|
||||
const registerRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/auth/register",
|
||||
payload: { email: "personalization-gate@example.invalid", password: "Password123!", displayName: "Gate" },
|
||||
});
|
||||
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
|
||||
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"], persons: 2, precisionMode: "simple" },
|
||||
});
|
||||
|
||||
// Without personalization consent: no personal signals read, no provenance.
|
||||
const withoutConsent = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/recommendations/what-to-eat?limit=5",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(withoutConsent.statusCode).toBe(200);
|
||||
const bodyWithout = JSON.parse(withoutConsent.body) as {
|
||||
recommendations: Array<{ provenance?: unknown[]; whySv: string }>;
|
||||
};
|
||||
expect(bodyWithout.recommendations.length).toBeGreaterThan(0);
|
||||
for (const r of bodyWithout.recommendations) {
|
||||
expect(r.provenance ?? []).toHaveLength(0);
|
||||
expect(r.whySv).not.toContain("berättat");
|
||||
}
|
||||
|
||||
// Grant personalization consent.
|
||||
await testDb.db
|
||||
.insert(schema.userConsents)
|
||||
.values({ userId, kind: "personalization", status: "granted" })
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.userConsents.userId, schema.userConsents.kind],
|
||||
set: { status: "granted" },
|
||||
});
|
||||
|
||||
const withConsent = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/recommendations/what-to-eat?limit=5",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(withConsent.statusCode).toBe(200);
|
||||
const bodyWith = JSON.parse(withConsent.body) as {
|
||||
recommendations: Array<{ recipeId: string; provenance?: unknown[]; score: number }>;
|
||||
};
|
||||
expect(bodyWith.recommendations.length).toBeGreaterThan(0);
|
||||
// Consent alone does not guarantee provenance; it just enables the path.
|
||||
// We verify determinism: same call twice = same order.
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/recommendations/what-to-eat?limit=5",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
const bodySecond = JSON.parse(second.body) as {
|
||||
recommendations: Array<{ recipeId: string; score: number }>;
|
||||
};
|
||||
expect(bodyWith.recommendations.map((r) => r.recipeId)).toEqual(
|
||||
bodySecond.recommendations.map((r) => r.recipeId),
|
||||
);
|
||||
});
|
||||
|
||||
it("quick-start auto-creates a household with default storage locations", async () => {
|
||||
const registerRes = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user