Répond à deux besoins : permettre à chaque foyer de choisir quelles sources apparaissent dans ses onglets de recettes, et distinguer les sources à API officielle des sources scrapées. - RecipeSourceAdapter.official (booléen, sans défaut — chaque adaptateur doit le déclarer explicitement) synchronisé sur Source.official par syncRecipeSources. - HouseSource : table de jointure opt-in (House <-> Source) — aucune ligne = source masquée. Un foyer nouvellement créé ne voit aucune source tant qu'il ne les active pas explicitement. - GET /reference/sources (catalogue des sources implémentées, avec le flag officiel). - GET/PATCH /house/current/sources (lecture/remplacement complet des sources activées par le foyer courant). - recipe.service.ts : sourceVisibilityWhere() filtre désormais TOUS les onglets (perso/foyer/publique/favoris) — une recette sans source reste toujours visible ; une recette importée ne l'est que si sa source est activée pour le foyer du viewer. Un viewer sans foyer ne voit aucune recette sourcée. Côté web : - Nouvelle étape /onboarding/sources dans le wizard d'inscription, atteinte uniquement si un foyer vient d'être créé/rejoint (sinon on saute direct aux allergènes) ; s'auto-saute aussi si aucune source n'est encore implémentée (catalogue vide aujourd'hui). - Nouvelle section « Sources de recettes » dans /parametres/foyer (masquée dans les mêmes conditions), avec sauvegarde à la volée (même pattern que les autres préférences hot-saved). - SourceSelect (features/house/), grille de cases à cocher avec badge officiel/non-officielle, sur le même principe qu'AllergySelect. 172 tests backend passent (dont 25 nouveaux). Build et lint propres. Vérifié manuellement en navigateur : le parcours d'onboarding saute bien l'étape sources (catalogue vide) et affiche « 4 sur 4 » quand un foyer a été créé ; la section paramètres reste invisible tant qu'aucune source n'existe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
205 lines
7 KiB
TypeScript
205 lines
7 KiB
TypeScript
import type { SignupInput } from "@batch-cooking/shared";
|
|
import { faker } from "@faker-js/faker";
|
|
import { expect } from "chai";
|
|
import request from "supertest";
|
|
import { createApp } from "../src/app.js";
|
|
import { prisma } from "../src/db/prisma.js";
|
|
import { findImportedExternalIds, syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
|
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
|
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
|
import { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
|
function buildSignupPayload(): SignupInput {
|
|
const firstName = faker.person.firstName();
|
|
const lastName = faker.person.lastName();
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
password: faker.internet.password({ length: 16 }),
|
|
};
|
|
}
|
|
|
|
/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official` matter for exercising `syncRecipeSources`. */
|
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
|
return {
|
|
key,
|
|
name,
|
|
official: false,
|
|
async list() {
|
|
return { items: [], nextCursor: null };
|
|
},
|
|
async fetchDetail() {
|
|
throw new Error("not implemented");
|
|
},
|
|
parse() {
|
|
throw new Error("not implemented");
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("recipe-source-sync", () => {
|
|
const app = createApp();
|
|
|
|
async function signup(): Promise<{ profileId: number }> {
|
|
const res = await request.agent(app).post("/auth/signup").send(buildSignupPayload());
|
|
return { profileId: res.body.id };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
clearRecipeSources();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearRecipeSources();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("syncRecipeSources", () => {
|
|
it("does nothing when the registry is empty", async () => {
|
|
await syncRecipeSources(prisma);
|
|
expect(await prisma.source.count()).to.equal(0);
|
|
});
|
|
|
|
it("creates a Source row per registered adapter", async () => {
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
|
|
await syncRecipeSources(prisma);
|
|
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
|
expect(source.name).to.equal("Fake Source");
|
|
});
|
|
|
|
it("is idempotent — running it twice doesn't duplicate rows", async () => {
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
|
|
await syncRecipeSources(prisma);
|
|
await syncRecipeSources(prisma);
|
|
|
|
expect(await prisma.source.count()).to.equal(1);
|
|
});
|
|
|
|
it("updates the name when the adapter's own name changes between syncs", async () => {
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Old Name"));
|
|
await syncRecipeSources(prisma);
|
|
clearRecipeSources();
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "New Name"));
|
|
|
|
await syncRecipeSources(prisma);
|
|
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
|
expect(source.name).to.equal("New Name");
|
|
});
|
|
|
|
it("never deletes a Source row whose key fell out of the registry", async () => {
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
await syncRecipeSources(prisma);
|
|
clearRecipeSources();
|
|
|
|
await syncRecipeSources(prisma);
|
|
|
|
expect(await prisma.source.count()).to.equal(1);
|
|
});
|
|
});
|
|
|
|
describe("findImportedExternalIds", () => {
|
|
it("returns an empty set for a sourceKey with no matching Source row", async () => {
|
|
expect(await findImportedExternalIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Set());
|
|
});
|
|
|
|
it("returns an empty set for an empty externalIds list", async () => {
|
|
expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set());
|
|
});
|
|
|
|
it("returns exactly the externalIds already imported from that source", async () => {
|
|
const { profileId } = await signup();
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
await syncRecipeSources(prisma);
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
|
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Tarte",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
// Manually-authored, not tied to any source — shouldn't ever show up as "imported".
|
|
await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } });
|
|
|
|
const result = await findImportedExternalIds(prisma, "fakeSource", ["1", "2", "3"]);
|
|
expect(result).to.deep.equal(new Set(["1"]));
|
|
});
|
|
|
|
it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => {
|
|
const { profileId } = await signup();
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
registerRecipeSource(buildFakeAdapter("otherSource", "Other Source"));
|
|
await syncRecipeSources(prisma);
|
|
const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } });
|
|
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Tarte",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: otherSource.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
|
|
expect(await findImportedExternalIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Set());
|
|
});
|
|
});
|
|
|
|
describe("Recipe(sourceId, externalId) uniqueness", () => {
|
|
it("rejects importing the same source recipe twice", async () => {
|
|
const { profileId } = await signup();
|
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
|
await syncRecipeSources(prisma);
|
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
|
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Tarte",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
|
|
let rejected = false;
|
|
try {
|
|
await prisma.recipe.create({
|
|
data: {
|
|
name: "Tarte (again)",
|
|
authorId: profileId,
|
|
portions: 4,
|
|
sourceId: source.id,
|
|
externalId: "1",
|
|
},
|
|
});
|
|
} catch {
|
|
rejected = true;
|
|
}
|
|
expect(rejected).to.be.true;
|
|
});
|
|
|
|
it("allows any number of manually-authored recipes (both columns null)", async () => {
|
|
const { profileId } = await signup();
|
|
|
|
await prisma.recipe.create({ data: { name: "Une", authorId: profileId, portions: 4 } });
|
|
await prisma.recipe.create({ data: { name: "Deux", authorId: profileId, portions: 4 } });
|
|
|
|
expect(await prisma.recipe.count()).to.equal(2);
|
|
});
|
|
});
|
|
});
|