feat(inventory): naturlig-språklig fritextsökning med FoodTwin-fakta, trust-hedge och i18n ×12

- Nytt endpoint GET /v1/inventory/natural-search.
- Robust matchning med unaccent + pg_trgm similarity mot display_name,
  brand och kanoniska ingrediensnamn (sv/en).
- Svar byggs deterministiskt från faktisk data: lagringsplats + sublocation
  om registrerad, trust-state-hedge från befintlig trust-motor.
- Server-sidan i18n-katalog för 12 språk.
- Tester för svensk träff och noll-träff på flera språk.
- Design-dokument docs/30-sökrobusthet-matvaror.md.
This commit is contained in:
Sven (AAMOS AI)
2026-08-08 05:53:06 +07:00
parent ddd6b736ba
commit 9197d93a92
5 changed files with 561 additions and 0 deletions
@@ -0,0 +1,136 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq, inArray } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
describe("GET /v1/inventory/natural-search", () => {
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
let app: Awaited<ReturnType<typeof buildServer>>;
beforeAll(async () => {
app = await buildServer(config);
await app.ready();
});
afterAll(async () => {
await closeDatabase();
await app.close();
});
async function registerUser(email: string) {
const res = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: { email, password: "Password123!", displayName: "Search Test" },
});
const body = JSON.parse(res.body) as { accessToken: string };
const token = body.accessToken;
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: ["less_waste"], precisionMode: "simple" },
});
return { token, userId };
}
async function cleanupUser(email: string) {
const existing = await testDb.db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.email, email));
for (const u of existing) {
const memberships = await testDb.db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, u.id));
const householdIds = memberships.map((m) => m.householdId);
if (householdIds.length > 0) {
await testDb.db.delete(schema.inventoryItems).where(inArray(schema.inventoryItems.householdId, householdIds));
await testDb.db.delete(schema.storageLocations).where(inArray(schema.storageLocations.householdId, householdIds));
await testDb.db.delete(schema.householdMembers).where(inArray(schema.householdMembers.householdId, householdIds));
await testDb.db.delete(schema.households).where(inArray(schema.households.id, householdIds));
}
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
}
}
it("returnerar ett mänskligt svar med plats och trust-hedge", async () => {
const email = "inventory-search-1@example.invalid";
await cleanupUser(email);
const { token, userId } = await registerUser(email);
const [household] = await testDb.db
.select({ id: schema.households.id })
.from(schema.households)
.innerJoin(schema.householdMembers, eq(schema.households.id, schema.householdMembers.householdId))
.where(eq(schema.householdMembers.userId, userId))
.limit(1);
const [fridge] = await testDb.db
.insert(schema.storageLocations)
.values({
householdId: household!.id,
type: "fridge",
name: "Kylen",
sublocations: ["mellanmålshyllan"],
})
.returning();
await testDb.db.insert(schema.inventoryItems).values({
householdId: household!.id,
displayName: "Mjölk",
quantity: 1,
unit: "LITER",
storageLocationId: fridge!.id,
sublocation: "mellanmålshyllan",
source: "manual_search",
confidence: 1,
verifiedByUser: true,
lastVerifiedAt: new Date(),
trustState: "trusted",
});
const res = await app.inject({
method: "GET",
url: "/v1/inventory/natural-search?q=mj%C3%B6lk&languageTag=sv-SE",
headers: { authorization: `Bearer ${token}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { response: string; items: unknown[] };
expect(body.response).toContain("Mjölk");
expect(body.response).toContain("Kylen");
expect(body.response).toContain("mellanmålshyllan");
expect(body.response).toContain("säker");
expect(body.items).toHaveLength(1);
await cleanupUser(email);
});
it("hanterar noll träffar på flera språk", async () => {
const email = "inventory-search-2@example.invalid";
await cleanupUser(email);
const { token } = await registerUser(email);
for (const languageTag of ["sv-SE", "en-GB", "de-DE"]) {
const res = await app.inject({
method: "GET",
url: `/v1/inventory/natural-search?q=unobtainium&languageTag=${encodeURIComponent(languageTag)}`,
headers: { authorization: `Bearer ${token}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { response: string; items: unknown[] };
expect(body.items).toHaveLength(0);
expect(body.response.length).toBeGreaterThan(10);
}
await cleanupUser(email);
});
});