ci: trigga på master + formatfix inför Gitea Actions
CI / Typecheck, test & build (push) Failing after 2s
CI / Typecheck, test & build (push) Failing after 2s
This commit is contained in:
@@ -84,9 +84,7 @@ export function AnalyticsPage() {
|
||||
};
|
||||
|
||||
const loadRetention = () => {
|
||||
api<RetentionResult>(
|
||||
`/admin/v1/analytics/retention?startDate=${retStart}&endDate=${retEnd}`,
|
||||
)
|
||||
api<RetentionResult>(`/admin/v1/analytics/retention?startDate=${retStart}&endDate=${retEnd}`)
|
||||
.then(setRetention)
|
||||
.catch((e) => setError(String(e.message)));
|
||||
};
|
||||
|
||||
@@ -114,10 +114,7 @@ export function ReleaseGatesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(gates.map((g) => g.category))),
|
||||
[gates],
|
||||
);
|
||||
const categories = useMemo(() => Array.from(new Set(gates.map((g) => g.category))), [gates]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (category === "all" ? gates : gates.filter((g) => g.category === category)),
|
||||
@@ -151,8 +148,14 @@ export function ReleaseGatesPage() {
|
||||
<div className="stat-grid" style={{ marginTop: "1rem" }}>
|
||||
<div className="stat">
|
||||
<div className="value">
|
||||
<span className={`badge ${summary.overall === "go" ? "ok" : summary.overall === "no_go" ? "err" : ""}`}>
|
||||
{summary.overall === "go" ? "GO" : summary.overall === "no_go" ? "NO-GO" : "Väntar"}
|
||||
<span
|
||||
className={`badge ${summary.overall === "go" ? "ok" : summary.overall === "no_go" ? "err" : ""}`}
|
||||
>
|
||||
{summary.overall === "go"
|
||||
? "GO"
|
||||
: summary.overall === "no_go"
|
||||
? "NO-GO"
|
||||
: "Väntar"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="label">Övergripande beslut</div>
|
||||
@@ -209,7 +212,9 @@ export function ReleaseGatesPage() {
|
||||
</td>
|
||||
<td>{g.evaluationWindowDays} dagar</td>
|
||||
<td>{g.blocking ? "Ja" : "Nej"}</td>
|
||||
<td>{g.lastEvaluatedAt ? new Date(g.lastEvaluatedAt).toLocaleString("sv-SE") : "–"}</td>
|
||||
<td>
|
||||
{g.lastEvaluatedAt ? new Date(g.lastEvaluatedAt).toLocaleString("sv-SE") : "–"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -32,10 +32,7 @@ async function main() {
|
||||
const usersWithoutHousehold = await db
|
||||
.select({ id: schema.users.id, locale: schema.users.locale })
|
||||
.from(schema.users)
|
||||
.leftJoin(
|
||||
schema.householdMembers,
|
||||
eq(schema.householdMembers.userId, schema.users.id),
|
||||
)
|
||||
.leftJoin(schema.householdMembers, eq(schema.householdMembers.userId, schema.users.id))
|
||||
.where(isNull(schema.householdMembers.userId));
|
||||
|
||||
console.log(`[backfill] Hittade ${usersWithoutHousehold.length} användare utan hushåll.`);
|
||||
|
||||
@@ -107,7 +107,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
process.exit(1);
|
||||
}
|
||||
if (cfg.AAMOS_MODE === "http" && (!cfg.AAMOS_API_URL || !cfg.AAMOS_API_KEY)) {
|
||||
console.error("SÄKERHETSSTOPP: AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY i produktion.");
|
||||
console.error(
|
||||
"SÄKERHETSSTOPP: AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY i produktion.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cfg.OPS_TOKEN || cfg.OPS_TOKEN.length < 32 || cfg.OPS_TOKEN.startsWith("dev-ops-token")) {
|
||||
|
||||
+51
-14
@@ -48,7 +48,13 @@ export interface CompleteCookingResult {
|
||||
mealBoxId: string | null;
|
||||
mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>;
|
||||
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
|
||||
recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>;
|
||||
recipeIngredients: Array<{
|
||||
canonicalIngredientId: string;
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
optional: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +72,8 @@ export async function completeCookingSession(
|
||||
): Promise<CompleteCookingResult & { session: typeof schema.cookingSessions.$inferSelect }> {
|
||||
const plannedPortions = input.portionsCooked ?? session.plannedPortions;
|
||||
const mealBoxPortions = input.mealBoxPortions ?? 0;
|
||||
const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
|
||||
const actualPortionsEaten =
|
||||
input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
|
||||
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions;
|
||||
|
||||
if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) {
|
||||
@@ -151,7 +158,11 @@ export async function completeCookingSession(
|
||||
sessionId: session.id,
|
||||
date,
|
||||
},
|
||||
existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
|
||||
existing ?? {
|
||||
averageEatenPortions: null,
|
||||
averageLeftoverPortions: null,
|
||||
observationCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
await app.db
|
||||
@@ -227,7 +238,9 @@ export async function completeCookingSessionCore(
|
||||
actualPortionsEaten + leftoverEstimatePortions,
|
||||
);
|
||||
const factor = consumptionPortions / recipe.portions;
|
||||
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
|
||||
const overrides = new Map(
|
||||
input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? [],
|
||||
);
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
if (ing.optional) continue;
|
||||
@@ -307,9 +320,7 @@ export async function completeCookingSessionCore(
|
||||
|
||||
// 2. Måltider
|
||||
const eaters =
|
||||
input.eaters && input.eaters.length > 0
|
||||
? input.eaters
|
||||
: [{ userId, portionFraction: 1 }];
|
||||
input.eaters && input.eaters.length > 0 ? input.eaters : [{ userId, portionFraction: 1 }];
|
||||
const mealIds: string[] = [];
|
||||
for (const eater of eaters) {
|
||||
const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction);
|
||||
@@ -396,7 +407,11 @@ export async function completeCookingSessionCore(
|
||||
})
|
||||
.where(eq(schema.mealBoxes.id, existingBox.id));
|
||||
mealBoxId = existingBox.id;
|
||||
mealBoxMutations.push({ mealBoxId: existingBox.id, deltaPortions: leftoverEstimatePortionsForBox, frozen });
|
||||
mealBoxMutations.push({
|
||||
mealBoxId: existingBox.id,
|
||||
deltaPortions: leftoverEstimatePortionsForBox,
|
||||
frozen,
|
||||
});
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_UPDATED",
|
||||
payload: {
|
||||
@@ -427,7 +442,11 @@ export async function completeCookingSessionCore(
|
||||
})
|
||||
.returning();
|
||||
mealBoxId = box!.id;
|
||||
mealBoxMutations.push({ mealBoxId: box!.id, deltaPortions: leftoverEstimatePortionsForBox, frozen });
|
||||
mealBoxMutations.push({
|
||||
mealBoxId: box!.id,
|
||||
deltaPortions: leftoverEstimatePortionsForBox,
|
||||
frozen,
|
||||
});
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_CREATED",
|
||||
payload: { mealBoxId: box!.id, portions: leftoverEstimatePortionsForBox },
|
||||
@@ -468,7 +487,11 @@ export async function completeCookingSessionCore(
|
||||
.where(eq(schema.recipes.id, session.recipeId));
|
||||
await emitEvent(app.db, {
|
||||
type: "RECIPE_COOKED",
|
||||
payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions: input.mealBoxPortions ?? 0 },
|
||||
payload: {
|
||||
recipeId: session.recipeId,
|
||||
portions: portionsCooked,
|
||||
mealBoxPortions: input.mealBoxPortions ?? 0,
|
||||
},
|
||||
userId,
|
||||
householdId,
|
||||
correlationId,
|
||||
@@ -578,7 +601,11 @@ export async function undoCookingSession(
|
||||
// 2. Återställ inventory-saldon från transaktionerna.
|
||||
for (const itemId of affectedItemIds) {
|
||||
const txs = await app.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -631,7 +658,11 @@ export async function undoCookingSession(
|
||||
discardedMealBoxIds.push(box.id);
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_DISCARDED",
|
||||
payload: { mealBoxId: box.id, portions: mutation.deltaPortions, source: "cooking_session_undo" },
|
||||
payload: {
|
||||
mealBoxId: box.id,
|
||||
portions: mutation.deltaPortions,
|
||||
source: "cooking_session_undo",
|
||||
},
|
||||
userId,
|
||||
householdId: session.householdId,
|
||||
correlationId,
|
||||
@@ -643,7 +674,11 @@ export async function undoCookingSession(
|
||||
.where(eq(schema.mealBoxes.id, box.id));
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_UPDATED",
|
||||
payload: { mealBoxId: box.id, addedPortions: -mutation.deltaPortions, totalPortions: newPortions },
|
||||
payload: {
|
||||
mealBoxId: box.id,
|
||||
addedPortions: -mutation.deltaPortions,
|
||||
totalPortions: newPortions,
|
||||
},
|
||||
userId,
|
||||
householdId: session.householdId,
|
||||
correlationId,
|
||||
@@ -673,7 +708,9 @@ export async function undoCookingSession(
|
||||
}
|
||||
|
||||
// 5. Ta bort recipe_cooks-raden och backa cookCount.
|
||||
await app.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, session.id));
|
||||
await app.db
|
||||
.delete(schema.recipeCooks)
|
||||
.where(eq(schema.recipeCooks.cookingSessionId, session.id));
|
||||
await app.db
|
||||
.update(schema.recipes)
|
||||
.set({ cookCount: sql`GREATEST(${schema.recipes.cookCount} - 1, 0)` })
|
||||
|
||||
@@ -155,7 +155,10 @@ export function newCorrelationId(): string {
|
||||
* funktionen returnerar alltid en giltig profil (default fallback). */
|
||||
export async function getActiveDecayProfile(db: Database): Promise<DecayProfile> {
|
||||
const [profile] = await db
|
||||
.select({ halfLifeDays: schema.inventoryDecayProfiles.halfLifeDays, staleAfterDays: schema.inventoryDecayProfiles.staleAfterDays })
|
||||
.select({
|
||||
halfLifeDays: schema.inventoryDecayProfiles.halfLifeDays,
|
||||
staleAfterDays: schema.inventoryDecayProfiles.staleAfterDays,
|
||||
})
|
||||
.from(schema.inventoryDecayProfiles)
|
||||
.where(eq(schema.inventoryDecayProfiles.active, true))
|
||||
.orderBy(schema.inventoryDecayProfiles.createdAt)
|
||||
|
||||
@@ -212,7 +212,10 @@ function formatList(items: SearchResultItem[], languageTag: string): string {
|
||||
const loc = formatLocation(item, languageTag);
|
||||
return `• ${item.displayName} ${loc}`;
|
||||
});
|
||||
const prefix = resolve(MULTI_RESULT_PREFIX, languageTag).replace("{{count}}", String(items.length));
|
||||
const prefix = resolve(MULTI_RESULT_PREFIX, languageTag).replace(
|
||||
"{{count}}",
|
||||
String(items.length),
|
||||
);
|
||||
const suffix = resolve(MULTI_RESULT_SUFFIX, languageTag);
|
||||
return `${prefix}\n${parts.join("\n")}\n${suffix}`;
|
||||
}
|
||||
@@ -221,10 +224,7 @@ function formatList(items: SearchResultItem[], languageTag: string): string {
|
||||
* Bygg ett naturligt-språkligt svar för fritextsökning i hushållslagret.
|
||||
* Ingen data fabriceras – allt som visas kommer från `items`.
|
||||
*/
|
||||
export function buildNaturalSearchResponse(
|
||||
items: SearchResultItem[],
|
||||
languageTag: string,
|
||||
): string {
|
||||
export function buildNaturalSearchResponse(items: SearchResultItem[], languageTag: string): string {
|
||||
if (items.length === 0) {
|
||||
return resolve(ZERO_RESULTS, languageTag);
|
||||
}
|
||||
|
||||
@@ -57,14 +57,28 @@ export interface MemoryImpactResult {
|
||||
* andra hushållsmedlemmars personliga data blir aldrig lästa eller returnerade.
|
||||
* - Återanvänder rankAll/scoreCandidate från recommendation-engine.
|
||||
*/
|
||||
export async function computeMemoryImpact(options: MemoryImpactOptions): Promise<MemoryImpactResult> {
|
||||
const { db, userId, memoryItem, mealType = "dinner", persons, maxMinutes, craving, view = "default", limit = 10 } = options;
|
||||
export async function computeMemoryImpact(
|
||||
options: MemoryImpactOptions,
|
||||
): Promise<MemoryImpactResult> {
|
||||
const {
|
||||
db,
|
||||
userId,
|
||||
memoryItem,
|
||||
mealType = "dinner",
|
||||
persons,
|
||||
maxMinutes,
|
||||
craving,
|
||||
view = "default",
|
||||
limit = 10,
|
||||
} = options;
|
||||
|
||||
// R2: personalization-samtycke krävs för impact.
|
||||
const [consent] = await db
|
||||
.select()
|
||||
.from(schema.userConsents)
|
||||
.where(and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "personalization")))
|
||||
.where(
|
||||
and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "personalization")),
|
||||
)
|
||||
.limit(1);
|
||||
if (consent?.status !== "granted") {
|
||||
return { memoryItemId: memoryItem.id, personalizationEnabled: false, impacted: [] };
|
||||
|
||||
@@ -141,11 +141,7 @@ export async function syncOnboardingMemory(
|
||||
origin: ONBOARDING_ORIGIN,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.tasteSignals.userId,
|
||||
schema.tasteSignals.axis,
|
||||
schema.tasteSignals.target,
|
||||
],
|
||||
target: [schema.tasteSignals.userId, schema.tasteSignals.axis, schema.tasteSignals.target],
|
||||
set: {
|
||||
direction: signal.direction,
|
||||
strength: TASTE_STRENGTH,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import fp from "fastify-plugin";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
MockStorage,
|
||||
AwsStorage,
|
||||
type StorageService,
|
||||
createStorageService,
|
||||
} from "@app/storage";
|
||||
import { MockStorage, AwsStorage, type StorageService, createStorageService } from "@app/storage";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
|
||||
@@ -21,7 +21,9 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
|
||||
|
||||
/** Compute a funnel between two UTC dates (YYYY-MM-DD). */
|
||||
app.get("/admin/v1/analytics/funnels/:name", admin, async (req) => {
|
||||
const params = z.object({ name: z.enum(Object.keys(FUNNELS) as [string, ...string[]]) }).parse(req.params);
|
||||
const params = z
|
||||
.object({ name: z.enum(Object.keys(FUNNELS) as [string, ...string[]]) })
|
||||
.parse(req.params);
|
||||
const query = z
|
||||
.object({
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
@@ -69,7 +71,10 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
|
||||
|
||||
const querySql = `WITH ${ctes.join(", ")} SELECT ${selects.join(", ")}`;
|
||||
const result = await app.db.execute(sql.raw(querySql));
|
||||
const row = (Array.isArray(result) ? result[0] : (result.rows[0] ?? {})) as Record<string, number>;
|
||||
const row = (Array.isArray(result) ? result[0] : (result.rows[0] ?? {})) as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
|
||||
const stepCounts = steps.map((event, i) => ({
|
||||
step: i + 1,
|
||||
@@ -97,7 +102,10 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
|
||||
const query = z
|
||||
.object({
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
endDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
})
|
||||
.parse(req.query);
|
||||
|
||||
@@ -147,7 +155,12 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
|
||||
string,
|
||||
{ cohortSize: number; retention: Array<{ day: number; active: number; rate: number }> }
|
||||
> = {};
|
||||
for (const r of rows as Array<{ cohort_date: string; day: number; cohort_size: number; active_users: number }>) {
|
||||
for (const r of rows as Array<{
|
||||
cohort_date: string;
|
||||
day: number;
|
||||
cohort_size: number;
|
||||
active_users: number;
|
||||
}>) {
|
||||
const key = String(r.cohort_date).slice(0, 10);
|
||||
if (!cohorts[key]) {
|
||||
cohorts[key] = { cohortSize: Number(r.cohort_size), retention: [] };
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { schema, evaluateReleaseGates, seedReleaseGates, summarizeReleaseGates } from "@app/database";
|
||||
import {
|
||||
schema,
|
||||
evaluateReleaseGates,
|
||||
seedReleaseGates,
|
||||
summarizeReleaseGates,
|
||||
} from "@app/database";
|
||||
import { releaseGateUpdateSchema } from "@app/validation";
|
||||
import { z } from "zod";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
@@ -40,7 +45,10 @@ export async function adminReleaseGateRoutes(app: FastifyInstance) {
|
||||
|
||||
/** List current gate definitions without re-evaluating. */
|
||||
app.get("/admin/v1/release-gates", admin, async () => {
|
||||
const gates = await app.db.select().from(schema.releaseGates).orderBy(schema.releaseGates.category, schema.releaseGates.gateKey);
|
||||
const gates = await app.db
|
||||
.select()
|
||||
.from(schema.releaseGates)
|
||||
.orderBy(schema.releaseGates.category, schema.releaseGates.gateKey);
|
||||
const summary = summarizeReleaseGates(
|
||||
gates.map((g) => ({
|
||||
gateKey: g.gateKey,
|
||||
|
||||
@@ -64,7 +64,9 @@ export async function adminWorkersRoutes(app: FastifyInstance) {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = await Job.fromId(dlq, id);
|
||||
if (!job) {
|
||||
return reply.status(404).send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } });
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } });
|
||||
}
|
||||
|
||||
const mainQueue = new Queue(JOB_QUEUE_NAME, { connection: app.redis });
|
||||
@@ -82,7 +84,9 @@ export async function adminWorkersRoutes(app: FastifyInstance) {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = await Job.fromId(dlq, id);
|
||||
if (!job) {
|
||||
return reply.status(404).send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } });
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } });
|
||||
}
|
||||
await job.remove();
|
||||
return reply.send({ ok: true, message: "Jobbet har tagits bort från dead letter queue." });
|
||||
|
||||
@@ -24,7 +24,8 @@ export async function analyticsRoutes(app: FastifyInstance) {
|
||||
rateLimit: {
|
||||
max: ANALYTICS_RATE_LIMIT_MAX,
|
||||
timeWindow: "1 minute",
|
||||
keyGenerator: (req) => `analytics:${req.ip}:${(req as unknown as { userId?: string }).userId ?? "anon"}`,
|
||||
keyGenerator: (req) =>
|
||||
`analytics:${req.ip}:${(req as unknown as { userId?: string }).userId ?? "anon"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -103,7 +103,11 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
|
||||
const params = z.object({ id: z.uuid() }).parse(req.params);
|
||||
const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {});
|
||||
const session = await getOwnedSession(app, params.id, req.userId);
|
||||
if (session.status === "completed" || session.status === "cancelled" || session.status === "undone") {
|
||||
if (
|
||||
session.status === "completed" ||
|
||||
session.status === "cancelled" ||
|
||||
session.status === "undone"
|
||||
) {
|
||||
throw errors.conflict("Sessionen är redan avslutad.");
|
||||
}
|
||||
|
||||
|
||||
@@ -461,7 +461,7 @@ export async function inventoryRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const trustState = computeTrust({
|
||||
confidence: updates.confidence as number | undefined ?? item.confidence,
|
||||
confidence: (updates.confidence as number | undefined) ?? item.confidence,
|
||||
verifiedByUser: (updates.verifiedByUser as boolean | undefined) ?? item.verifiedByUser,
|
||||
lastVerifiedAt: (updates.lastVerifiedAt as Date | null | undefined) ?? item.lastVerifiedAt,
|
||||
quantity: (updates.quantity as number | undefined) ?? item.quantity,
|
||||
|
||||
@@ -173,7 +173,9 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
const [row] = await app.db
|
||||
.insert(schema.userTermsConsents)
|
||||
.values({ userId: req.userId, termsVersion: TERMS_VERSION, acceptedAt: now })
|
||||
.onConflictDoNothing({ target: [schema.userTermsConsents.userId, schema.userTermsConsents.termsVersion] })
|
||||
.onConflictDoNothing({
|
||||
target: [schema.userTermsConsents.userId, schema.userTermsConsents.termsVersion],
|
||||
})
|
||||
.returning();
|
||||
return {
|
||||
accepted: true,
|
||||
|
||||
@@ -4,11 +4,7 @@ import { z } from "zod";
|
||||
import { schema } from "@app/database";
|
||||
import { quickStartInputSchema, onboardingInputSchema } from "@app/validation";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import {
|
||||
audit,
|
||||
createHouseholdWithDefaults,
|
||||
getActiveHouseholdId,
|
||||
} from "../lib/helpers.js";
|
||||
import { audit, createHouseholdWithDefaults, getActiveHouseholdId } from "../lib/helpers.js";
|
||||
|
||||
import { t } from "../lib/i18n.js";
|
||||
import { KNOWN_FLAGS } from "@app/feature-flags";
|
||||
@@ -70,11 +66,7 @@ export async function onboardingRoutes(app: FastifyInstance) {
|
||||
const input = parse(quickStartInputSchema, req.body);
|
||||
|
||||
// Normalize goals: multi-select UI sends `goals`; legacy clients send `primaryGoal`.
|
||||
const goals = input.goals?.length
|
||||
? input.goals
|
||||
: input.primaryGoal
|
||||
? [input.primaryGoal]
|
||||
: [];
|
||||
const goals = input.goals?.length ? input.goals : input.primaryGoal ? [input.primaryGoal] : [];
|
||||
const primaryGoal = goals[0];
|
||||
|
||||
// Upsert minimal preferences
|
||||
@@ -262,10 +254,7 @@ export async function onboardingRoutes(app: FastifyInstance) {
|
||||
|
||||
/** POST /v1/onboarding/skip – skip remaining steps (GDPR-friendly, user's choice). */
|
||||
app.post("/v1/onboarding/skip", auth, async (req) => {
|
||||
const body = parse(
|
||||
z.object({ step: z.enum(["b", "c"]).optional() }),
|
||||
req.body,
|
||||
);
|
||||
const body = parse(z.object({ step: z.enum(["b", "c"]).optional() }), req.body);
|
||||
|
||||
const [user] = await app.db
|
||||
.update(schema.users)
|
||||
|
||||
@@ -433,9 +433,16 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
const result = await completeCookingSession(app, session!, req.userId, input, req.correlationId, {
|
||||
emitStartedEvent: true,
|
||||
});
|
||||
const result = await completeCookingSession(
|
||||
app,
|
||||
session!,
|
||||
req.userId,
|
||||
input,
|
||||
req.correlationId,
|
||||
{
|
||||
emitStartedEvent: true,
|
||||
},
|
||||
);
|
||||
|
||||
return { sessionId: session!.id, ...result };
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@ import { schema } from "@app/database";
|
||||
import { z } from "zod";
|
||||
import { buildReconciliationCandidates, classifyExpiry, computeTrust } from "@app/inventory-engine";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { getActiveDecayProfile, requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js";
|
||||
import {
|
||||
reconciliationResolveInputSchema,
|
||||
reconciliationStartInputSchema,
|
||||
} from "@app/validation";
|
||||
getActiveDecayProfile,
|
||||
requireActiveHousehold,
|
||||
requireMembership,
|
||||
trackProductAnalytics,
|
||||
} from "../lib/helpers.js";
|
||||
import { reconciliationResolveInputSchema, reconciliationStartInputSchema } from "@app/validation";
|
||||
import { inventoryReconciliationCompleted, inventoryReconciliationStarted } from "@app/analytics";
|
||||
|
||||
/** Quick Reconciliation (Fas 2 §5.4) */
|
||||
@@ -63,10 +65,15 @@ export async function reconciliationRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
if (upcomingEntries.length > 0) {
|
||||
const recipeIds = [...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean))] as string[];
|
||||
const recipeIds = [
|
||||
...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean)),
|
||||
] as string[];
|
||||
if (recipeIds.length > 0) {
|
||||
const ingredients = await app.db
|
||||
.select({ recipeId: schema.recipeIngredients.recipeId, canonicalId: schema.recipeIngredients.canonicalIngredientId })
|
||||
.select({
|
||||
recipeId: schema.recipeIngredients.recipeId,
|
||||
canonicalId: schema.recipeIngredients.canonicalIngredientId,
|
||||
})
|
||||
.from(schema.recipeIngredients)
|
||||
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
|
||||
for (const ing of ingredients) {
|
||||
@@ -255,7 +262,11 @@ export async function reconciliationRoutes(app: FastifyInstance) {
|
||||
req.userId,
|
||||
inventoryReconciliationCompleted({
|
||||
householdId,
|
||||
properties: { itemId: params.itemId, action: input.action, hadAdjustment: quantityChange !== 0 },
|
||||
properties: {
|
||||
itemId: params.itemId,
|
||||
action: input.action,
|
||||
hadAdjustment: quantityChange !== 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import { diffScans, type ScanDiffRow, computeBalance } from "@app/inventory-engi
|
||||
import { z } from "zod";
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js";
|
||||
import {
|
||||
requireActiveHousehold,
|
||||
requireMembership,
|
||||
trackProductAnalytics,
|
||||
} from "../lib/helpers.js";
|
||||
import { scanDiffApplyInputSchema } from "@app/validation";
|
||||
import { inventoryConflictCreated, inventoryConflictResolved } from "@app/analytics";
|
||||
|
||||
@@ -25,7 +29,11 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
await requireMembership(app.db, householdId, req.userId);
|
||||
|
||||
const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1);
|
||||
const [job] = await app.db
|
||||
.select()
|
||||
.from(schema.scanJobs)
|
||||
.where(eq(schema.scanJobs.id, params.id))
|
||||
.limit(1);
|
||||
if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte.");
|
||||
if (!job.result) throw errors.badRequest("Skanningen har inget resultat än.");
|
||||
|
||||
@@ -57,7 +65,10 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
kind: row.kind,
|
||||
displayName: row.displayName,
|
||||
previousItemId: row.previousItemId,
|
||||
proposedResolution: row.kind === "vanished" ? "depleted" : { quantity: row.newQuantity, locationId: row.newLocationId },
|
||||
proposedResolution:
|
||||
row.kind === "vanished"
|
||||
? "depleted"
|
||||
: { quantity: row.newQuantity, locationId: row.newLocationId },
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -96,14 +107,19 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1);
|
||||
const [job] = await app.db
|
||||
.select()
|
||||
.from(schema.scanJobs)
|
||||
.where(eq(schema.scanJobs.id, params.id))
|
||||
.limit(1);
|
||||
if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte.");
|
||||
|
||||
const appliedItemIds: string[] = [];
|
||||
|
||||
for (const row of input.rows) {
|
||||
if (row.kind === "new_item") {
|
||||
const locationId = row.newLocationId ?? (await defaultLocation(app, householdId, job.scanType));
|
||||
const locationId =
|
||||
row.newLocationId ?? (await defaultLocation(app, householdId, job.scanType));
|
||||
if (!locationId) continue;
|
||||
const [inv] = await app.db
|
||||
.insert(schema.inventoryItems)
|
||||
@@ -219,7 +235,11 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
const txs = await app.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -253,7 +273,12 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
const conflicts = await app.db
|
||||
.select()
|
||||
.from(schema.inventoryConflicts)
|
||||
.where(and(eq(schema.inventoryConflicts.householdId, householdId), eq(schema.inventoryConflicts.status, "open")))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryConflicts.householdId, householdId),
|
||||
eq(schema.inventoryConflicts.status, "open"),
|
||||
),
|
||||
)
|
||||
.orderBy(schema.inventoryConflicts.createdAt);
|
||||
return { conflicts };
|
||||
});
|
||||
@@ -278,7 +303,8 @@ export async function scanDiffRoutes(app: FastifyInstance) {
|
||||
.from(schema.inventoryConflicts)
|
||||
.where(eq(schema.inventoryConflicts.id, params.id))
|
||||
.limit(1);
|
||||
if (!conflict || conflict.householdId !== householdId) throw errors.notFound("Konflikten finns inte.");
|
||||
if (!conflict || conflict.householdId !== householdId)
|
||||
throw errors.notFound("Konflikten finns inte.");
|
||||
if (conflict.status !== "open") throw errors.badRequest("Konflikten är redan löst.");
|
||||
|
||||
const resolved = resolveConflictPayload(conflict, body);
|
||||
@@ -349,7 +375,11 @@ function extractObservations(result: Record<string, unknown> | unknown[]): Array
|
||||
useByDate?: string | null;
|
||||
observationConfidence: number;
|
||||
}> {
|
||||
const raw = Array.isArray(result) ? result : Array.isArray(result.items) ? (result.items as unknown[]) : [];
|
||||
const raw = Array.isArray(result)
|
||||
? result
|
||||
: Array.isArray(result.items)
|
||||
? (result.items as unknown[])
|
||||
: [];
|
||||
return raw
|
||||
.map((r) => {
|
||||
const item = r as Record<string, unknown>;
|
||||
@@ -382,18 +412,31 @@ function scanSource(scanType: string) {
|
||||
|
||||
async function defaultLocation(app: FastifyInstance, householdId: string, scanType: string) {
|
||||
const wanted =
|
||||
scanType === "freezer" ? "freezer" : scanType === "pantry" || scanType === "ingredients" ? "pantry" : "fridge";
|
||||
scanType === "freezer"
|
||||
? "freezer"
|
||||
: scanType === "pantry" || scanType === "ingredients"
|
||||
? "pantry"
|
||||
: "fridge";
|
||||
const [loc] = await app.db
|
||||
.select({ id: schema.storageLocations.id })
|
||||
.from(schema.storageLocations)
|
||||
.where(and(eq(schema.storageLocations.householdId, householdId), eq(schema.storageLocations.type, wanted)))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.storageLocations.householdId, householdId),
|
||||
eq(schema.storageLocations.type, wanted),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return loc?.id ?? null;
|
||||
}
|
||||
|
||||
function resolveConflictPayload(
|
||||
conflict: typeof schema.inventoryConflicts.$inferSelect,
|
||||
body: { resolution: "source_a" | "source_b" | "manual"; manualQuantity?: number; manualLocationId?: string },
|
||||
body: {
|
||||
resolution: "source_a" | "source_b" | "manual";
|
||||
manualQuantity?: number;
|
||||
manualLocationId?: string;
|
||||
},
|
||||
): { quantity?: number; locationId?: string; status?: string } {
|
||||
const a = (conflict.payloadA ?? {}) as Record<string, unknown>;
|
||||
const b = (conflict.payloadB ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -19,18 +19,29 @@ describe("admin household trust score", () => {
|
||||
.from(schema.users)
|
||||
.where(inArray(schema.users.email, [adminEmail]));
|
||||
for (const u of existing) {
|
||||
await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.actorUserId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.actorUserId, u.id));
|
||||
const owned = await testDb.db
|
||||
.select({ id: schema.households.id })
|
||||
.from(schema.households)
|
||||
.innerJoin(schema.householdMembers, eq(schema.householdMembers.householdId, schema.households.id))
|
||||
.innerJoin(
|
||||
schema.householdMembers,
|
||||
eq(schema.householdMembers.householdId, schema.households.id),
|
||||
)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
for (const h of owned) {
|
||||
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.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));
|
||||
}
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||
}
|
||||
@@ -74,7 +85,12 @@ describe("admin household trust score", () => {
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { householdId: string; score: number; status: string; itemCount: number };
|
||||
const body = JSON.parse(res.body) as {
|
||||
householdId: string;
|
||||
score: number;
|
||||
status: string;
|
||||
itemCount: number;
|
||||
};
|
||||
expect(body.householdId).toBe(householdId);
|
||||
expect(typeof body.score).toBe("number");
|
||||
expect(["up_to_date", "needs_check", "uncertain"]).toContain(body.status);
|
||||
|
||||
@@ -18,7 +18,12 @@ describe("cooking sessions", () => {
|
||||
let recipeId: string;
|
||||
const email = "cooking-session-test@example.invalid";
|
||||
|
||||
async function createItemWithPurchase(canonicalIngredientId: string, displayName: string, quantity: number, unit: Unit) {
|
||||
async function createItemWithPurchase(
|
||||
canonicalIngredientId: string,
|
||||
displayName: string,
|
||||
quantity: number,
|
||||
unit: Unit,
|
||||
) {
|
||||
const [location] = await testDb.db
|
||||
.select({ id: schema.storageLocations.id })
|
||||
.from(schema.storageLocations)
|
||||
@@ -64,26 +69,46 @@ describe("cooking sessions", () => {
|
||||
.where(eq(schema.inventoryTransactions.cookingSessionId, s.id));
|
||||
await testDb.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, s.id));
|
||||
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, s.id));
|
||||
await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, s.id));
|
||||
await testDb.db
|
||||
.delete(schema.recipeCooks)
|
||||
.where(eq(schema.recipeCooks.cookingSessionId, s.id));
|
||||
}
|
||||
await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.startedByUserId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.cookingSessions)
|
||||
.where(eq(schema.cookingSessions.startedByUserId, u.id));
|
||||
const memberships = await testDb.db
|
||||
.select({ householdId: schema.householdMembers.householdId })
|
||||
.from(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
for (const m of memberships) {
|
||||
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.meals).where(eq(schema.meals.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.recipeCooks)
|
||||
.where(eq(schema.recipeCooks.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.cookingSessions)
|
||||
.where(eq(schema.cookingSessions.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.storageLocations)
|
||||
.where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.cookingAssumptionProfiles)
|
||||
.where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
|
||||
}
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, u.id));
|
||||
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||
}
|
||||
}
|
||||
@@ -99,7 +124,11 @@ describe("cooking sessions", () => {
|
||||
payload: { email, password: "Password123!", displayName: "Cooking Test" },
|
||||
});
|
||||
token = (JSON.parse(res.body) as { accessToken: string }).accessToken;
|
||||
const profile = await app.inject({ method: "GET", url: "/v1/me", headers: { authorization: `Bearer ${token}` } });
|
||||
const profile = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/me",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
userId = (JSON.parse(profile.body) as { id: string }).id;
|
||||
|
||||
const quick = await app.inject({
|
||||
@@ -168,7 +197,9 @@ describe("cooking sessions", () => {
|
||||
.from(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, userId))
|
||||
.orderBy(schema.productAnalyticsEvents.occurredAt);
|
||||
expect(started.filter((e) => e.eventName === "cooking_session_started").length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
started.filter((e) => e.eventName === "cooking_session_started").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("cancels a session without touching inventory", async () => {
|
||||
@@ -183,7 +214,10 @@ describe("cooking sessions", () => {
|
||||
const before = await testDb.db
|
||||
.select({ count: count(schema.inventoryTransactions.id) })
|
||||
.from(schema.inventoryTransactions)
|
||||
.innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id))
|
||||
.innerJoin(
|
||||
schema.inventoryItems,
|
||||
eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id),
|
||||
)
|
||||
.where(eq(schema.inventoryItems.householdId, householdId));
|
||||
|
||||
const res = await app.inject({
|
||||
@@ -199,7 +233,10 @@ describe("cooking sessions", () => {
|
||||
const after = await testDb.db
|
||||
.select({ count: count(schema.inventoryTransactions.id) })
|
||||
.from(schema.inventoryTransactions)
|
||||
.innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id))
|
||||
.innerJoin(
|
||||
schema.inventoryItems,
|
||||
eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id),
|
||||
)
|
||||
.where(eq(schema.inventoryItems.householdId, householdId));
|
||||
expect(after[0]!.count).toBe(before[0]!.count);
|
||||
});
|
||||
@@ -248,7 +285,11 @@ describe("cooking sessions", () => {
|
||||
.where(eq(schema.inventoryItems.id, d.itemId))
|
||||
.limit(1);
|
||||
const txs = await testDb.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, d.itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -264,7 +305,11 @@ describe("cooking sessions", () => {
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 2, deductInventory: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { ok: boolean; sessionId: string; mealBoxId: string | null };
|
||||
const body = JSON.parse(res.body) as {
|
||||
ok: boolean;
|
||||
sessionId: string;
|
||||
mealBoxId: string | null;
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.sessionId).toBeDefined();
|
||||
expect(body.mealBoxId).toBeDefined();
|
||||
@@ -298,14 +343,20 @@ describe("cooking sessions", () => {
|
||||
});
|
||||
|
||||
it("legacy /cook updates cooking assumption profiles", async () => {
|
||||
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
await testDb.db
|
||||
.delete(schema.cookingAssumptionProfiles)
|
||||
.where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
|
||||
const recipe = (await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||
const recipe = (
|
||||
await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptionalIngredientId = recipe.ingredients.find(
|
||||
(i) => i.canonicalIngredientId && !i.optional,
|
||||
)?.canonicalIngredientId;
|
||||
expect(firstNonOptionalIngredientId).toBeDefined();
|
||||
|
||||
await app.inject({
|
||||
@@ -338,7 +389,10 @@ describe("cooking sessions", () => {
|
||||
|
||||
it("legacy /cook writes cooking_session_started and cooking_session_completed analytics", async () => {
|
||||
const before = await testDb.db
|
||||
.select({ id: schema.productAnalyticsEvents.id, eventName: schema.productAnalyticsEvents.eventName })
|
||||
.select({
|
||||
id: schema.productAnalyticsEvents.id,
|
||||
eventName: schema.productAnalyticsEvents.eventName,
|
||||
})
|
||||
.from(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, userId));
|
||||
const beforeIds = new Set(before.map((e) => e.id));
|
||||
@@ -357,9 +411,14 @@ describe("cooking sessions", () => {
|
||||
.from(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, userId));
|
||||
const newEvents = after.filter((e) => !beforeIds.has(e.id));
|
||||
const props = (e: (typeof after)[number]) => (e.properties ?? {}) as { cookingSessionId?: string };
|
||||
const started = newEvents.filter((e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId);
|
||||
const completed = newEvents.filter((e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId);
|
||||
const props = (e: (typeof after)[number]) =>
|
||||
(e.properties ?? {}) as { cookingSessionId?: string };
|
||||
const started = newEvents.filter(
|
||||
(e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId,
|
||||
);
|
||||
const completed = newEvents.filter(
|
||||
(e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId,
|
||||
);
|
||||
expect(started.length).toBe(1);
|
||||
expect(completed.length).toBe(1);
|
||||
});
|
||||
@@ -395,7 +454,9 @@ describe("cooking sessions", () => {
|
||||
payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { session: { actualPortionsEaten: number; leftoverEstimatePortions: number } };
|
||||
const body = JSON.parse(res.body) as {
|
||||
session: { actualPortionsEaten: number; leftoverEstimatePortions: number };
|
||||
};
|
||||
expect(body.session.actualPortionsEaten).toBe(2);
|
||||
expect(body.session.leftoverEstimatePortions).toBe(1);
|
||||
});
|
||||
@@ -419,7 +480,9 @@ describe("cooking sessions", () => {
|
||||
});
|
||||
|
||||
it("updates cooking assumption profiles per household and ingredient", async () => {
|
||||
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
await testDb.db
|
||||
.delete(schema.cookingAssumptionProfiles)
|
||||
.where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
@@ -429,12 +492,16 @@ describe("cooking sessions", () => {
|
||||
});
|
||||
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||
|
||||
const recipe = (await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||
const recipe = (
|
||||
await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptionalIngredientId = recipe.ingredients.find(
|
||||
(i) => i.canonicalIngredientId && !i.optional,
|
||||
)?.canonicalIngredientId;
|
||||
expect(firstNonOptionalIngredientId).toBeDefined();
|
||||
|
||||
await app.inject({
|
||||
@@ -461,7 +528,9 @@ describe("cooking sessions", () => {
|
||||
});
|
||||
|
||||
it("cooking-assumptions ignores optional first ingredient and returns defaults from a non-optional one", async () => {
|
||||
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
await testDb.db
|
||||
.delete(schema.cookingAssumptionProfiles)
|
||||
.where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
|
||||
// Skapa ett recept där den första ingrediensen är valfri.
|
||||
const [recipe] = await testDb.db
|
||||
@@ -480,7 +549,16 @@ describe("cooking sessions", () => {
|
||||
cookTimeMinutes: 10,
|
||||
totalTimeMinutes: 15,
|
||||
portions: 4,
|
||||
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 100,
|
||||
proteinG: 5,
|
||||
fatG: 3,
|
||||
carbsG: 12,
|
||||
saturatedFatG: 1,
|
||||
fiberG: 1,
|
||||
sugarG: 2,
|
||||
saltG: 0.1,
|
||||
},
|
||||
allergens: [],
|
||||
spiceLevel: 0,
|
||||
dna: {
|
||||
@@ -554,7 +632,9 @@ describe("cooking sessions", () => {
|
||||
expect(body.defaultLeftoverEstimatePortions).toBe(1);
|
||||
|
||||
// Städa upp testreceptet.
|
||||
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db
|
||||
.delete(schema.recipeIngredients)
|
||||
.where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||
});
|
||||
|
||||
@@ -619,12 +699,16 @@ describe("cooking sessions", () => {
|
||||
});
|
||||
|
||||
it("undo restores inventory, removes meals, discards meal boxes and deletes recipe_cooks", async () => {
|
||||
const recipe = (await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptional = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||
const recipe = (
|
||||
await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptional = recipe.ingredients.find(
|
||||
(i) => i.canonicalIngredientId && !i.optional,
|
||||
)?.canonicalIngredientId;
|
||||
|
||||
// Sätt upp ett känt lager om receptet har en icke-valfri ingrediens.
|
||||
let itemId: string | undefined;
|
||||
@@ -660,7 +744,11 @@ describe("cooking sessions", () => {
|
||||
payload: {},
|
||||
});
|
||||
expect(undo.statusCode).toBe(200);
|
||||
const undoBody = JSON.parse(undo.body) as { ok: boolean; removedMealIds: string[]; discardedMealBoxIds: string[] };
|
||||
const undoBody = JSON.parse(undo.body) as {
|
||||
ok: boolean;
|
||||
removedMealIds: string[];
|
||||
discardedMealBoxIds: string[];
|
||||
};
|
||||
expect(undoBody.ok).toBe(true);
|
||||
|
||||
// Sessionstatus = undone.
|
||||
@@ -679,16 +767,25 @@ describe("cooking sessions", () => {
|
||||
expect(Number(txsAfter[0]!.count)).toBeGreaterThan(Number(txsBefore[0]!.count));
|
||||
|
||||
// Meals borttagna.
|
||||
const meals = await testDb.db.select({ count: count(schema.meals.id) }).from(schema.meals).where(eq(schema.meals.cookingSessionId, sessionId));
|
||||
const meals = await testDb.db
|
||||
.select({ count: count(schema.meals.id) })
|
||||
.from(schema.meals)
|
||||
.where(eq(schema.meals.cookingSessionId, sessionId));
|
||||
expect(Number(meals[0]!.count)).toBe(0);
|
||||
|
||||
// Matlådor markerade discarded.
|
||||
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
const boxes = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
expect(boxes.length).toBe(undoBody.discardedMealBoxIds.length);
|
||||
for (const box of boxes) expect(box.status).toBe("discarded");
|
||||
|
||||
// recipe_cooks borttagen och cookCount backad.
|
||||
const cooks = await testDb.db.select().from(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, sessionId));
|
||||
const cooks = await testDb.db
|
||||
.select()
|
||||
.from(schema.recipeCooks)
|
||||
.where(eq(schema.recipeCooks.cookingSessionId, sessionId));
|
||||
expect(cooks.length).toBe(0);
|
||||
|
||||
// Inventory-transaktionsinvariant.
|
||||
@@ -699,7 +796,11 @@ describe("cooking sessions", () => {
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
const itemTxs = await testDb.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(itemTxs);
|
||||
@@ -712,13 +813,16 @@ describe("cooking sessions", () => {
|
||||
.from(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, userId))
|
||||
.orderBy(schema.productAnalyticsEvents.occurredAt);
|
||||
const props = (e: (typeof analytics)[number]) => (e.properties ?? {}) as { cookingSessionId?: string };
|
||||
expect(analytics.some((e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId)).toBe(true);
|
||||
const props = (e: (typeof analytics)[number]) =>
|
||||
(e.properties ?? {}) as { cookingSessionId?: string };
|
||||
expect(
|
||||
analytics.some(
|
||||
(e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
if (itemId) {
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId));
|
||||
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.id, itemId));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -771,10 +875,28 @@ describe("cooking sessions", () => {
|
||||
cookTimeMinutes: 10,
|
||||
totalTimeMinutes: 15,
|
||||
portions: 4,
|
||||
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 100,
|
||||
proteinG: 5,
|
||||
fatG: 3,
|
||||
carbsG: 12,
|
||||
saturatedFatG: 1,
|
||||
fiberG: 1,
|
||||
sugarG: 2,
|
||||
saltG: 0.1,
|
||||
},
|
||||
allergens: [],
|
||||
spiceLevel: 0,
|
||||
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||
dna: {
|
||||
cuisine: "international",
|
||||
vegetables: [],
|
||||
flavorProfile: [],
|
||||
spiceLevel: 0,
|
||||
method: "stovetop",
|
||||
timeMinutes: 15,
|
||||
calories: 100,
|
||||
proteinGrams: 5,
|
||||
},
|
||||
status: "published",
|
||||
verificationStatus: "unverified",
|
||||
sourceType: "own_editorial",
|
||||
@@ -794,7 +916,12 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry")));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"),
|
||||
),
|
||||
);
|
||||
const itemId = await createItemWithPurchase("pasta_dry", "Pasta", 400, "GRAM");
|
||||
|
||||
const start = await app.inject({
|
||||
@@ -838,8 +965,15 @@ describe("cooking sessions", () => {
|
||||
// Städa testreceptet och lagerposten.
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry")));
|
||||
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"),
|
||||
),
|
||||
);
|
||||
await testDb.db
|
||||
.delete(schema.recipeIngredients)
|
||||
.where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||
});
|
||||
|
||||
@@ -879,10 +1013,28 @@ describe("cooking sessions", () => {
|
||||
cookTimeMinutes: 10,
|
||||
totalTimeMinutes: 15,
|
||||
portions: 4,
|
||||
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 100,
|
||||
proteinG: 5,
|
||||
fatG: 3,
|
||||
carbsG: 12,
|
||||
saturatedFatG: 1,
|
||||
fiberG: 1,
|
||||
sugarG: 2,
|
||||
saltG: 0.1,
|
||||
},
|
||||
allergens: [],
|
||||
spiceLevel: 0,
|
||||
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||
dna: {
|
||||
cuisine: "international",
|
||||
vegetables: [],
|
||||
flavorProfile: [],
|
||||
spiceLevel: 0,
|
||||
method: "stovetop",
|
||||
timeMinutes: 15,
|
||||
calories: 100,
|
||||
proteinGrams: 5,
|
||||
},
|
||||
status: "published",
|
||||
verificationStatus: "unverified",
|
||||
sourceType: "own_editorial",
|
||||
@@ -902,7 +1054,12 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast")));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast"),
|
||||
),
|
||||
);
|
||||
const itemId = await createItemWithPurchase("chicken_breast", "Kyckling", 400, "GRAM");
|
||||
|
||||
const start = await app.inject({
|
||||
@@ -930,13 +1087,20 @@ describe("cooking sessions", () => {
|
||||
expect(item[0]!.quantity).toBeCloseTo(0, 1);
|
||||
|
||||
// Matlådan ska ha 2 portioner.
|
||||
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
const boxes = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
expect(boxes.length).toBe(1);
|
||||
expect(boxes[0]!.portions).toBe(2);
|
||||
|
||||
// Invariant.
|
||||
const txs = await testDb.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -944,8 +1108,15 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast")));
|
||||
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast"),
|
||||
),
|
||||
);
|
||||
await testDb.db
|
||||
.delete(schema.recipeIngredients)
|
||||
.where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||
});
|
||||
|
||||
@@ -966,10 +1137,28 @@ describe("cooking sessions", () => {
|
||||
cookTimeMinutes: 10,
|
||||
totalTimeMinutes: 15,
|
||||
portions: 4,
|
||||
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 100,
|
||||
proteinG: 5,
|
||||
fatG: 3,
|
||||
carbsG: 12,
|
||||
saturatedFatG: 1,
|
||||
fiberG: 1,
|
||||
sugarG: 2,
|
||||
saltG: 0.1,
|
||||
},
|
||||
allergens: [],
|
||||
spiceLevel: 0,
|
||||
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||
dna: {
|
||||
cuisine: "international",
|
||||
vegetables: [],
|
||||
flavorProfile: [],
|
||||
spiceLevel: 0,
|
||||
method: "stovetop",
|
||||
timeMinutes: 15,
|
||||
calories: 100,
|
||||
proteinGrams: 5,
|
||||
},
|
||||
status: "published",
|
||||
verificationStatus: "unverified",
|
||||
sourceType: "own_editorial",
|
||||
@@ -989,7 +1178,12 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot")));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "carrot"),
|
||||
),
|
||||
);
|
||||
const itemId = await createItemWithPurchase("carrot", "Morötter", 400, "GRAM");
|
||||
|
||||
const start = await app.inject({
|
||||
@@ -1017,7 +1211,11 @@ describe("cooking sessions", () => {
|
||||
expect(item[0]!.quantity).toBeCloseTo(100, 1);
|
||||
|
||||
const txs = await testDb.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -1025,8 +1223,15 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot")));
|
||||
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "carrot"),
|
||||
),
|
||||
);
|
||||
await testDb.db
|
||||
.delete(schema.recipeIngredients)
|
||||
.where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||
});
|
||||
|
||||
@@ -1047,10 +1252,28 @@ describe("cooking sessions", () => {
|
||||
cookTimeMinutes: 10,
|
||||
totalTimeMinutes: 15,
|
||||
portions: 4,
|
||||
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 100,
|
||||
proteinG: 5,
|
||||
fatG: 3,
|
||||
carbsG: 12,
|
||||
saturatedFatG: 1,
|
||||
fiberG: 1,
|
||||
sugarG: 2,
|
||||
saltG: 0.1,
|
||||
},
|
||||
allergens: [],
|
||||
spiceLevel: 0,
|
||||
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||
dna: {
|
||||
cuisine: "international",
|
||||
vegetables: [],
|
||||
flavorProfile: [],
|
||||
spiceLevel: 0,
|
||||
method: "stovetop",
|
||||
timeMinutes: 15,
|
||||
calories: 100,
|
||||
proteinGrams: 5,
|
||||
},
|
||||
status: "published",
|
||||
verificationStatus: "unverified",
|
||||
sourceType: "own_editorial",
|
||||
@@ -1070,7 +1293,12 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "potato")));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "potato"),
|
||||
),
|
||||
);
|
||||
const itemId = await createItemWithPurchase("potato", "Potatis", 400, "GRAM");
|
||||
|
||||
const start = await app.inject({
|
||||
@@ -1099,13 +1327,20 @@ describe("cooking sessions", () => {
|
||||
expect(item[0]!.quantity).toBeCloseTo(0, 1);
|
||||
|
||||
// Lådan ska ha 1 portion.
|
||||
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
const boxes = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
expect(boxes.length).toBe(1);
|
||||
expect(boxes[0]!.portions).toBe(1);
|
||||
|
||||
// Invariant.
|
||||
const txs = await testDb.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -1113,8 +1348,15 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "potato")));
|
||||
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "potato"),
|
||||
),
|
||||
);
|
||||
await testDb.db
|
||||
.delete(schema.recipeIngredients)
|
||||
.where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||
});
|
||||
|
||||
@@ -1135,10 +1377,28 @@ describe("cooking sessions", () => {
|
||||
cookTimeMinutes: 10,
|
||||
totalTimeMinutes: 15,
|
||||
portions: 4,
|
||||
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 100,
|
||||
proteinG: 5,
|
||||
fatG: 3,
|
||||
carbsG: 12,
|
||||
saturatedFatG: 1,
|
||||
fiberG: 1,
|
||||
sugarG: 2,
|
||||
saltG: 0.1,
|
||||
},
|
||||
allergens: [],
|
||||
spiceLevel: 0,
|
||||
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||
dna: {
|
||||
cuisine: "international",
|
||||
vegetables: [],
|
||||
flavorProfile: [],
|
||||
spiceLevel: 0,
|
||||
method: "stovetop",
|
||||
timeMinutes: 15,
|
||||
calories: 100,
|
||||
proteinGrams: 5,
|
||||
},
|
||||
status: "published",
|
||||
verificationStatus: "unverified",
|
||||
sourceType: "own_editorial",
|
||||
@@ -1158,7 +1418,12 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white")));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "rice_white"),
|
||||
),
|
||||
);
|
||||
const itemId = await createItemWithPurchase("rice_white", "Ris", 400, "GRAM");
|
||||
|
||||
async function assertInvariant() {
|
||||
@@ -1168,7 +1433,11 @@ describe("cooking sessions", () => {
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
const txs = await testDb.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
@@ -1217,8 +1486,15 @@ describe("cooking sessions", () => {
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white")));
|
||||
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, "rice_white"),
|
||||
),
|
||||
);
|
||||
await testDb.db
|
||||
.delete(schema.recipeIngredients)
|
||||
.where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||
});
|
||||
|
||||
@@ -1247,26 +1523,40 @@ describe("cooking sessions", () => {
|
||||
.limit(1);
|
||||
expect(session[0]!.status).toBe("undone");
|
||||
|
||||
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
const boxes = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
expect(boxes.every((b) => b.status === "discarded")).toBe(true);
|
||||
});
|
||||
|
||||
it("undo rolls back cooking assumption profiles", async () => {
|
||||
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
await testDb.db
|
||||
.delete(schema.cookingAssumptionProfiles)
|
||||
.where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
|
||||
const recipe = (await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||
const recipe = (
|
||||
await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptionalIngredientId = recipe.ingredients.find(
|
||||
(i) => i.canonicalIngredientId && !i.optional,
|
||||
)?.canonicalIngredientId;
|
||||
expect(firstNonOptionalIngredientId).toBeDefined();
|
||||
|
||||
const cook = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, actualPortionsEaten: 3, leftoverEstimatePortions: 1, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
actualPortionsEaten: 3,
|
||||
leftoverEstimatePortions: 1,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
const { sessionId } = JSON.parse(cook.body) as { sessionId: string };
|
||||
|
||||
@@ -1315,12 +1605,20 @@ describe("cooking sessions", () => {
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 1,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
expect(cook1.statusCode).toBe(200);
|
||||
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||
|
||||
const boxes1 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1));
|
||||
const boxes1 = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.cookingSessionId, sessionId1));
|
||||
expect(boxes1.length).toBe(1);
|
||||
const boxId = boxes1[0]!.id;
|
||||
|
||||
@@ -1328,12 +1626,20 @@ describe("cooking sessions", () => {
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 2,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
expect(cook2.statusCode).toBe(200);
|
||||
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||
|
||||
const boxes2 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
|
||||
const boxes2 = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.id, boxId));
|
||||
expect(boxes2[0]!.portions).toBe(3);
|
||||
expect(boxes2[0]!.portionsRemaining).toBe(3);
|
||||
|
||||
@@ -1352,7 +1658,13 @@ describe("cooking sessions", () => {
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, date: yesterday, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 1,
|
||||
mealBoxFrozen: false,
|
||||
date: yesterday,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
expect(cook1.statusCode).toBe(200);
|
||||
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||
@@ -1361,7 +1673,12 @@ describe("cooking sessions", () => {
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 1,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
expect(cook2.statusCode).toBe(200);
|
||||
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||
@@ -1385,7 +1702,12 @@ describe("cooking sessions", () => {
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 1,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
expect(cook1.statusCode).toBe(200);
|
||||
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||
@@ -1394,7 +1716,12 @@ describe("cooking sessions", () => {
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: true, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 1,
|
||||
mealBoxFrozen: true,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
expect(cook2.statusCode).toBe(200);
|
||||
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||
@@ -1420,20 +1747,38 @@ describe("cooking sessions", () => {
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 1,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||
const boxId = (await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1)))[0]!.id;
|
||||
const boxId = (
|
||||
await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.cookingSessionId, sessionId1))
|
||||
)[0]!.id;
|
||||
|
||||
const cook2 = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true },
|
||||
payload: {
|
||||
portionsCooked: 4,
|
||||
mealBoxPortions: 2,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
},
|
||||
});
|
||||
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||
|
||||
const beforeUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
|
||||
const beforeUndo = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.id, boxId));
|
||||
expect(beforeUndo[0]!.portions).toBe(3);
|
||||
|
||||
const undo = await app.inject({
|
||||
@@ -1444,7 +1789,10 @@ describe("cooking sessions", () => {
|
||||
});
|
||||
expect(undo.statusCode).toBe(200);
|
||||
|
||||
const afterUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
|
||||
const afterUndo = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.id, boxId));
|
||||
expect(afterUndo[0]!.portions).toBe(1);
|
||||
expect(afterUndo[0]!.portionsRemaining).toBe(1);
|
||||
expect(afterUndo[0]!.status).toBe("available");
|
||||
@@ -1469,7 +1817,10 @@ describe("cooking sessions", () => {
|
||||
});
|
||||
expect(undo.statusCode).toBe(200);
|
||||
|
||||
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
const boxes = await testDb.db
|
||||
.select()
|
||||
.from(schema.mealBoxes)
|
||||
.where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
expect(boxes[0]!.status).toBe("discarded");
|
||||
expect(boxes[0]!.portionsRemaining).toBe(0);
|
||||
});
|
||||
@@ -1504,7 +1855,11 @@ describe("cooking sessions", () => {
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs);
|
||||
const item = await testDb.db.select().from(schema.inventoryItems).where(eq(schema.inventoryItems.id, itemId)).limit(1);
|
||||
const item = await testDb.db
|
||||
.select()
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
expect(item[0]!.quantity).toBeCloseTo(balance.balance, 6);
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,10 @@ describe("S6 minnes-i18n + transparens-paritet", () => {
|
||||
expect(renderMemorySummary({ summarySv: "", value }, "sv-SE")).toBe(expectedSv);
|
||||
for (const lang of EXPECTED_LANGS) {
|
||||
const rendered = renderMemorySummary({ summarySv: "", value }, `${lang}-XX`);
|
||||
expect(rendered.length, `tom summary för ${lang}, ${JSON.stringify(value)}`).toBeGreaterThan(0);
|
||||
expect(
|
||||
rendered.length,
|
||||
`tom summary för ${lang}, ${JSON.stringify(value)}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(rendered).not.toBe("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +52,18 @@ describe("GET /v1/inventory/natural-search", () => {
|
||||
.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.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));
|
||||
}
|
||||
@@ -69,7 +77,10 @@ describe("GET /v1/inventory/natural-search", () => {
|
||||
const [household] = await testDb.db
|
||||
.select({ id: schema.households.id })
|
||||
.from(schema.households)
|
||||
.innerJoin(schema.householdMembers, eq(schema.households.id, schema.householdMembers.householdId))
|
||||
.innerJoin(
|
||||
schema.householdMembers,
|
||||
eq(schema.households.id, schema.householdMembers.householdId),
|
||||
)
|
||||
.where(eq(schema.householdMembers.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
|
||||
@@ -20,18 +20,29 @@ describe("inventory trust read-time computation", () => {
|
||||
.from(schema.users)
|
||||
.where(inArray(schema.users.email, [userEmail]));
|
||||
for (const u of existing) {
|
||||
await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.actorUserId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.actorUserId, u.id));
|
||||
const owned = await testDb.db
|
||||
.select({ id: schema.households.id })
|
||||
.from(schema.households)
|
||||
.innerJoin(schema.householdMembers, eq(schema.householdMembers.householdId, schema.households.id))
|
||||
.innerJoin(
|
||||
schema.householdMembers,
|
||||
eq(schema.householdMembers.householdId, schema.households.id),
|
||||
)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
for (const h of owned) {
|
||||
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.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));
|
||||
}
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||
}
|
||||
@@ -99,7 +110,9 @@ describe("inventory trust read-time computation", () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.body) as { items: Array<{ id: string; trustState: string; trustScore: number }> };
|
||||
const body = JSON.parse(res.body) as {
|
||||
items: Array<{ id: string; trustState: string; trustScore: number }>;
|
||||
};
|
||||
const found = body.items.find((i) => i.id === item!.id);
|
||||
expect(found).toBeTruthy();
|
||||
expect(["decaying", "stale"]).toContain(found!.trustState);
|
||||
|
||||
@@ -28,8 +28,12 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
await testDb.db.delete(schema.recipeRatings).where(eq(schema.recipeRatings.userId, u.id));
|
||||
await testDb.db.delete(schema.recipeFavorites).where(eq(schema.recipeFavorites.userId, u.id));
|
||||
await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.userId, u.id));
|
||||
await testDb.db.delete(schema.creatorFollows).where(eq(schema.creatorFollows.followerUserId, u.id));
|
||||
await testDb.db.delete(schema.creatorFollows).where(eq(schema.creatorFollows.creatorUserId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.creatorFollows)
|
||||
.where(eq(schema.creatorFollows.followerUserId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.creatorFollows)
|
||||
.where(eq(schema.creatorFollows.creatorUserId, u.id));
|
||||
await testDb.db.delete(schema.creatorStats).where(eq(schema.creatorStats.userId, u.id));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.creatorUserId, u.id));
|
||||
await testDb.db.delete(schema.foodMemories).where(eq(schema.foodMemories.userId, u.id));
|
||||
@@ -37,8 +41,12 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id));
|
||||
await testDb.db.delete(schema.meals).where(eq(schema.meals.userId, u.id));
|
||||
await testDb.db.delete(schema.subscriptions).where(eq(schema.subscriptions.userId, u.id));
|
||||
await testDb.db.delete(schema.subscriptionEvents).where(eq(schema.subscriptionEvents.userId, u.id));
|
||||
await testDb.db.delete(schema.storeTransactions).where(eq(schema.storeTransactions.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.subscriptionEvents)
|
||||
.where(eq(schema.subscriptionEvents.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.storeTransactions)
|
||||
.where(eq(schema.storeTransactions.userId, u.id));
|
||||
await testDb.db.delete(schema.trials).where(eq(schema.trials.userId, u.id));
|
||||
await testDb.db.delete(schema.aiUsageCounters).where(eq(schema.aiUsageCounters.userId, u.id));
|
||||
await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, u.id));
|
||||
@@ -46,22 +54,34 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
await testDb.db.delete(schema.idempotencyKeys).where(eq(schema.idempotencyKeys.userId, u.id));
|
||||
await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id));
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, u.id));
|
||||
await testDb.db.delete(schema.userLocalePreferences).where(eq(schema.userLocalePreferences.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.userHealthProfiles)
|
||||
.where(eq(schema.userHealthProfiles.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.userLocalePreferences)
|
||||
.where(eq(schema.userLocalePreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id));
|
||||
await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id));
|
||||
await testDb.db.delete(schema.emailVerificationTokens).where(eq(schema.emailVerificationTokens.userId, u.id));
|
||||
await testDb.db.delete(schema.passwordResetTokens).where(eq(schema.passwordResetTokens.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.emailVerificationTokens)
|
||||
.where(eq(schema.emailVerificationTokens.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.passwordResetTokens)
|
||||
.where(eq(schema.passwordResetTokens.userId, u.id));
|
||||
await testDb.db.delete(schema.adminTotp).where(eq(schema.adminTotp.userId, u.id));
|
||||
await testDb.db.delete(schema.auditLogs).where(eq(schema.auditLogs.actorUserId, u.id));
|
||||
await testDb.db.delete(schema.domainEvents).where(eq(schema.domainEvents.userId, u.id));
|
||||
await testDb.db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, u.id));
|
||||
// Remove memberships and any orphaned single-member households.
|
||||
const memberships = await testDb.db
|
||||
.select({ householdId: schema.householdMembers.householdId })
|
||||
.from(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
for (const { householdId } of memberships) {
|
||||
const remaining = await testDb.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
@@ -124,7 +144,9 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
{ userId, kind: "personalization", status: "granted" },
|
||||
{ userId, kind: "image_training", status: "granted" },
|
||||
]);
|
||||
await testDb.db.insert(schema.pushTokens).values({ userId, token: "expo-token-1", platform: "ios" });
|
||||
await testDb.db
|
||||
.insert(schema.pushTokens)
|
||||
.values({ userId, token: "expo-token-1", platform: "ios" });
|
||||
await testDb.db.insert(schema.notifications).values({
|
||||
userId,
|
||||
type: "subscription_status",
|
||||
@@ -201,7 +223,16 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
source: "manual",
|
||||
titleSv: "Resttest-lunch",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
nutrition: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
|
||||
nutrition: {
|
||||
kcal: 0,
|
||||
proteinG: 0,
|
||||
carbsG: 0,
|
||||
fatG: 0,
|
||||
saturatedFatG: 0,
|
||||
fiberG: 0,
|
||||
sugarG: 0,
|
||||
saltG: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const [draftRecipe] = await testDb.db
|
||||
@@ -215,7 +246,16 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
creatorUserId: userId,
|
||||
creatorDisplayName: "Test",
|
||||
status: "draft",
|
||||
nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 0,
|
||||
proteinG: 0,
|
||||
carbsG: 0,
|
||||
fatG: 0,
|
||||
saturatedFatG: 0,
|
||||
fiberG: 0,
|
||||
sugarG: 0,
|
||||
saltG: 0,
|
||||
},
|
||||
dna: {
|
||||
cuisine: "swedish",
|
||||
vegetables: [],
|
||||
@@ -241,7 +281,16 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
creatorDisplayName: "Test",
|
||||
status: "published",
|
||||
verificationStatus: "editorial",
|
||||
nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 0,
|
||||
proteinG: 0,
|
||||
carbsG: 0,
|
||||
fatG: 0,
|
||||
saturatedFatG: 0,
|
||||
fiberG: 0,
|
||||
sugarG: 0,
|
||||
saltG: 0,
|
||||
},
|
||||
dna: {
|
||||
cuisine: "swedish",
|
||||
vegetables: [],
|
||||
@@ -255,9 +304,15 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
})
|
||||
.returning();
|
||||
|
||||
await testDb.db.insert(schema.recipeRatings).values({ recipeId: publishedRecipe!.id, userId, stars: 5 });
|
||||
await testDb.db.insert(schema.recipeFavorites).values({ recipeId: publishedRecipe!.id, userId });
|
||||
await testDb.db.insert(schema.recipeCooks).values({ recipeId: publishedRecipe!.id, userId, portionsCooked: 2 });
|
||||
await testDb.db
|
||||
.insert(schema.recipeRatings)
|
||||
.values({ recipeId: publishedRecipe!.id, userId, stars: 5 });
|
||||
await testDb.db
|
||||
.insert(schema.recipeFavorites)
|
||||
.values({ recipeId: publishedRecipe!.id, userId });
|
||||
await testDb.db
|
||||
.insert(schema.recipeCooks)
|
||||
.values({ recipeId: publishedRecipe!.id, userId, portionsCooked: 2 });
|
||||
await testDb.db.insert(schema.creatorStats).values({ userId });
|
||||
|
||||
await testDb.db.insert(schema.subscriptions).values({
|
||||
@@ -431,7 +486,10 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
const result = await testDb.db.execute<{ count: number }>(
|
||||
sql.raw(`SELECT count(*)::int AS count FROM "${table}" WHERE "${column}" = '${userId}'`),
|
||||
);
|
||||
expect(Number(result.rows[0]?.count ?? 0), `Residual ${table}.${column} for deleted user`).toBe(0);
|
||||
expect(
|
||||
Number(result.rows[0]?.count ?? 0),
|
||||
`Residual ${table}.${column} for deleted user`,
|
||||
).toBe(0);
|
||||
}
|
||||
|
||||
// Receipts in surviving households must have image stripped.
|
||||
@@ -440,12 +498,18 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
.from(schema.receipts)
|
||||
.where(eq(schema.receipts.householdId, (await getHouseholdId(userId))!));
|
||||
for (const r of survivingReceipts) {
|
||||
expect(r.imageUrl, "Receipt image must be null after deletion in surviving household").toBeNull();
|
||||
expect(
|
||||
r.imageUrl,
|
||||
"Receipt image must be null after deletion in surviving household",
|
||||
).toBeNull();
|
||||
}
|
||||
|
||||
// Public recipe must be anonymized.
|
||||
const [publicAfter] = await testDb.db
|
||||
.select({ creatorUserId: schema.recipes.creatorUserId, creatorDisplayName: schema.recipes.creatorDisplayName })
|
||||
.select({
|
||||
creatorUserId: schema.recipes.creatorUserId,
|
||||
creatorDisplayName: schema.recipes.creatorDisplayName,
|
||||
})
|
||||
.from(schema.recipes)
|
||||
.where(eq(schema.recipes.id, publishedRecipe!.id));
|
||||
expect(publicAfter?.creatorUserId).toBeNull();
|
||||
@@ -460,7 +524,11 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
||||
|
||||
// User row is soft-deleted and anonymized.
|
||||
const [userAfter] = await testDb.db
|
||||
.select({ email: schema.users.email, displayName: schema.users.displayName, deletedAt: schema.users.deletedAt })
|
||||
.select({
|
||||
email: schema.users.email,
|
||||
displayName: schema.users.displayName,
|
||||
deletedAt: schema.users.deletedAt,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, userId));
|
||||
expect(userAfter?.deletedAt).not.toBeNull();
|
||||
|
||||
@@ -21,14 +21,12 @@ describe("DELETE /v1/me — GDPR-radering", () => {
|
||||
.from(schema.aiCorrections)
|
||||
.where(eq(schema.aiCorrections.userId, u.id));
|
||||
if (corrections.length > 0) {
|
||||
await testDb.db
|
||||
.delete(schema.aiTrainingBank)
|
||||
.where(
|
||||
inArray(
|
||||
schema.aiTrainingBank.correctionId,
|
||||
corrections.map((r) => r.id),
|
||||
),
|
||||
);
|
||||
await testDb.db.delete(schema.aiTrainingBank).where(
|
||||
inArray(
|
||||
schema.aiTrainingBank.correctionId,
|
||||
corrections.map((r) => r.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
await testDb.db.delete(schema.aiCorrections).where(eq(schema.aiCorrections.userId, u.id));
|
||||
await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id));
|
||||
|
||||
@@ -41,7 +41,9 @@ describe("/v1/me/memory", () => {
|
||||
await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id));
|
||||
await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id));
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
const ownedHouseholds = await testDb.db
|
||||
.select({ id: schema.households.id })
|
||||
.from(schema.households)
|
||||
@@ -53,7 +55,9 @@ describe("/v1/me/memory", () => {
|
||||
and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")),
|
||||
);
|
||||
for (const h of ownedHouseholds) {
|
||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.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));
|
||||
}
|
||||
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||
@@ -100,7 +104,11 @@ describe("/v1/me/memory", () => {
|
||||
});
|
||||
|
||||
expect(patchRes.statusCode).toBe(200);
|
||||
const body = JSON.parse(patchRes.body) as { origin: string; confidence: number; verifiedByUser: boolean };
|
||||
const body = JSON.parse(patchRes.body) as {
|
||||
origin: string;
|
||||
confidence: number;
|
||||
verifiedByUser: boolean;
|
||||
};
|
||||
expect(body.origin).toBe("user_stated");
|
||||
expect(body.confidence).toBe(1);
|
||||
expect(body.verifiedByUser).toBe(true);
|
||||
@@ -237,7 +245,9 @@ describe("DELETE /v1/me/memory GDPR-regression", () => {
|
||||
await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id));
|
||||
await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id));
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, u.id));
|
||||
const ownedHouseholds = await testDb.db
|
||||
.select({ id: schema.households.id })
|
||||
.from(schema.households)
|
||||
@@ -249,7 +259,9 @@ describe("DELETE /v1/me/memory GDPR-regression", () => {
|
||||
and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")),
|
||||
);
|
||||
for (const h of ownedHouseholds) {
|
||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.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));
|
||||
}
|
||||
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||
|
||||
@@ -19,7 +19,9 @@ describe("S5 — onboarding → minne + smaksignaler", () => {
|
||||
await testDb.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, u.id));
|
||||
await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id));
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
await testDb.db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, u.id));
|
||||
await testDb.db
|
||||
.delete(schema.userHealthProfiles)
|
||||
.where(eq(schema.userHealthProfiles.userId, u.id));
|
||||
await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id));
|
||||
await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id));
|
||||
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||
@@ -102,16 +104,36 @@ describe("S5 — onboarding → minne + smaksignaler", () => {
|
||||
.select()
|
||||
.from(schema.tasteSignals)
|
||||
.where(eq(schema.tasteSignals.userId, userId));
|
||||
const cuisineSignals = signals.filter((s) => s.axis === "cuisine").sort((a, b) => (a.target ?? "").localeCompare(b.target ?? ""));
|
||||
const avoidSignals = signals.filter((s) => s.axis === "ingredient_avoid").sort((a, b) => (a.target ?? "").localeCompare(b.target ?? ""));
|
||||
const cuisineSignals = signals
|
||||
.filter((s) => s.axis === "cuisine")
|
||||
.sort((a, b) => (a.target ?? "").localeCompare(b.target ?? ""));
|
||||
const avoidSignals = signals
|
||||
.filter((s) => s.axis === "ingredient_avoid")
|
||||
.sort((a, b) => (a.target ?? "").localeCompare(b.target ?? ""));
|
||||
|
||||
expect(cuisineSignals).toHaveLength(2);
|
||||
expect(cuisineSignals[0]).toMatchObject({ target: "italian", direction: 1, origin: "user_stated" });
|
||||
expect(cuisineSignals[1]).toMatchObject({ target: "thai", direction: 1, origin: "user_stated" });
|
||||
expect(cuisineSignals[0]).toMatchObject({
|
||||
target: "italian",
|
||||
direction: 1,
|
||||
origin: "user_stated",
|
||||
});
|
||||
expect(cuisineSignals[1]).toMatchObject({
|
||||
target: "thai",
|
||||
direction: 1,
|
||||
origin: "user_stated",
|
||||
});
|
||||
|
||||
expect(avoidSignals).toHaveLength(2);
|
||||
expect(avoidSignals[0]).toMatchObject({ target: "anchovy", direction: -1, origin: "user_stated" });
|
||||
expect(avoidSignals[1]).toMatchObject({ target: "broccoli", direction: -1, origin: "user_stated" });
|
||||
expect(avoidSignals[0]).toMatchObject({
|
||||
target: "anchovy",
|
||||
direction: -1,
|
||||
origin: "user_stated",
|
||||
});
|
||||
expect(avoidSignals[1]).toMatchObject({
|
||||
target: "broccoli",
|
||||
direction: -1,
|
||||
origin: "user_stated",
|
||||
});
|
||||
|
||||
await cleanupUser(email);
|
||||
});
|
||||
@@ -219,8 +241,14 @@ describe("S5 — onboarding → minne + smaksignaler", () => {
|
||||
.from(schema.tasteSignals)
|
||||
.where(eq(schema.tasteSignals.userId, userId));
|
||||
expect(signals).toHaveLength(2);
|
||||
expect(signals.some((s) => s.axis === "cuisine" && s.target === "italian" && s.direction === 1)).toBe(true);
|
||||
expect(signals.some((s) => s.axis === "ingredient_avoid" && s.target === "mushroom" && s.direction === -1)).toBe(true);
|
||||
expect(
|
||||
signals.some((s) => s.axis === "cuisine" && s.target === "italian" && s.direction === 1),
|
||||
).toBe(true);
|
||||
expect(
|
||||
signals.some(
|
||||
(s) => s.axis === "ingredient_avoid" && s.target === "mushroom" && s.direction === -1,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await cleanupUser(email);
|
||||
});
|
||||
|
||||
@@ -29,15 +29,13 @@ describe("progressive onboarding validering (FAS 1b)", () => {
|
||||
});
|
||||
|
||||
it("quick-start avvisar ogiltigt mål", () => {
|
||||
expect(() =>
|
||||
parse(quickStartInputSchema, { primaryGoal: "invalid_goal" }),
|
||||
).toThrowError(ApiError);
|
||||
expect(() => parse(quickStartInputSchema, { primaryGoal: "invalid_goal" })).toThrowError(
|
||||
ApiError,
|
||||
);
|
||||
});
|
||||
|
||||
it("quick-start avvisar ogiltig precision", () => {
|
||||
expect(() =>
|
||||
parse(quickStartInputSchema, { precisionMode: "medium" }),
|
||||
).toThrowError(ApiError);
|
||||
expect(() => parse(quickStartInputSchema, { precisionMode: "medium" })).toThrowError(ApiError);
|
||||
});
|
||||
|
||||
it("onboarding-status schema validerar korrekt struktur", () => {
|
||||
|
||||
@@ -30,11 +30,19 @@ describe("quick reconciliation", () => {
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
for (const it of items) {
|
||||
await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
|
||||
}
|
||||
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.storageLocations)
|
||||
.where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
|
||||
}
|
||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||
@@ -97,7 +105,9 @@ describe("quick reconciliation", () => {
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { candidates: Array<{ itemId: string; reasons: unknown[]; suggestedAction: string }> };
|
||||
const body = JSON.parse(res.body) as {
|
||||
candidates: Array<{ itemId: string; reasons: unknown[]; suggestedAction: string }>;
|
||||
};
|
||||
expect(body.candidates.length).toBeGreaterThan(0);
|
||||
expect(body.candidates[0]?.reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -119,7 +129,11 @@ describe("quick reconciliation", () => {
|
||||
payload: { action: "exists", quantity: 0.5 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { action: string; quantity: number; verifiedByUser: boolean };
|
||||
const body = JSON.parse(res.body) as {
|
||||
action: string;
|
||||
quantity: number;
|
||||
verifiedByUser: boolean;
|
||||
};
|
||||
expect(body.action).toBe("exists");
|
||||
expect(body.quantity).toBe(0.5);
|
||||
expect(body.verifiedByUser).toBe(true);
|
||||
@@ -142,10 +156,16 @@ describe("quick reconciliation", () => {
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
const txs = await testDb.db
|
||||
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
|
||||
.select({
|
||||
type: schema.inventoryTransactions.type,
|
||||
quantityDelta: schema.inventoryTransactions.quantityDelta,
|
||||
unit: schema.inventoryTransactions.unit,
|
||||
})
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
|
||||
const balance = computeBalance(txs.map((tx) => ({ type: tx.type, quantityDelta: tx.quantityDelta, unit: tx.unit })));
|
||||
const balance = computeBalance(
|
||||
txs.map((tx) => ({ type: tx.type, quantityDelta: tx.quantityDelta, unit: tx.unit })),
|
||||
);
|
||||
expect(balance.balance).toBeCloseTo(item!.quantity, 5);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,13 +31,25 @@ describe("scan-to-scan-diff", () => {
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
for (const it of items) {
|
||||
await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
|
||||
await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.inventoryItemId, it.id));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryConflicts)
|
||||
.where(eq(schema.inventoryConflicts.inventoryItemId, it.id));
|
||||
}
|
||||
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryConflicts)
|
||||
.where(eq(schema.inventoryConflicts.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.storageLocations)
|
||||
.where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
|
||||
}
|
||||
await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id));
|
||||
@@ -107,8 +119,20 @@ describe("scan-to-scan-diff", () => {
|
||||
jobType: "ANALYZE_FRIDGE_IMAGE",
|
||||
status: "completed",
|
||||
result: [
|
||||
{ displayName: "Mjölk", quantity: 0.5, unit: "LITER", storageLocationId: location!.id, confidence: 0.9 },
|
||||
{ displayName: "Ost", quantity: 1, unit: "COUNT", storageLocationId: location!.id, confidence: 0.9 },
|
||||
{
|
||||
displayName: "Mjölk",
|
||||
quantity: 0.5,
|
||||
unit: "LITER",
|
||||
storageLocationId: location!.id,
|
||||
confidence: 0.9,
|
||||
},
|
||||
{
|
||||
displayName: "Ost",
|
||||
quantity: 1,
|
||||
unit: "COUNT",
|
||||
storageLocationId: location!.id,
|
||||
confidence: 0.9,
|
||||
},
|
||||
],
|
||||
modelVersion: "v1",
|
||||
promptVersion: "p1",
|
||||
@@ -151,7 +175,17 @@ describe("scan-to-scan-diff", () => {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
const { rows } = JSON.parse(diff.body) as { rows: Array<{ kind: string; itemId?: string; previousItemId?: string; displayName: string; newQuantity?: number; newLocationId?: string; confidence: number }> };
|
||||
const { rows } = JSON.parse(diff.body) as {
|
||||
rows: Array<{
|
||||
kind: string;
|
||||
itemId?: string;
|
||||
previousItemId?: string;
|
||||
displayName: string;
|
||||
newQuantity?: number;
|
||||
newLocationId?: string;
|
||||
confidence: number;
|
||||
}>;
|
||||
};
|
||||
const changed = rows.find((r) => r.kind === "quantity_changed")!;
|
||||
|
||||
const apply = await app.inject({
|
||||
|
||||
@@ -33,13 +33,25 @@ describe("scan confirmation → ai_corrections", () => {
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
for (const it of items) {
|
||||
await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
|
||||
await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.inventoryItemId, it.id));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.inventoryItemId, it.id));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryConflicts)
|
||||
.where(eq(schema.inventoryConflicts.inventoryItemId, it.id));
|
||||
}
|
||||
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.inventoryConflicts)
|
||||
.where(eq(schema.inventoryConflicts.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.storageLocations)
|
||||
.where(eq(schema.storageLocations.householdId, m.householdId));
|
||||
await testDb.db
|
||||
.delete(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.householdId, m.householdId));
|
||||
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
|
||||
}
|
||||
await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id));
|
||||
@@ -62,7 +74,11 @@ describe("scan confirmation → ai_corrections", () => {
|
||||
}
|
||||
await testDb.db
|
||||
.insert(schema.userConsents)
|
||||
.values({ userId, kind: "image_training" as const, status: (imageTraining ? "granted" : "denied") as "granted" | "denied" })
|
||||
.values({
|
||||
userId,
|
||||
kind: "image_training" as const,
|
||||
status: (imageTraining ? "granted" : "denied") as "granted" | "denied",
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.userConsents.userId, schema.userConsents.kind],
|
||||
set: { status: (imageTraining ? "granted" : "denied") as "granted" | "denied" },
|
||||
@@ -168,7 +184,9 @@ describe("scan confirmation → ai_corrections", () => {
|
||||
expect(corrections).toHaveLength(1);
|
||||
expect((corrections[0]!.userCorrection as Record<string, string>).action).toBe("accept");
|
||||
expect((corrections[0]!.proposal as Record<string, unknown>).detectedName).toBe("Mellanmjölk");
|
||||
expect((corrections[0]!.userCorrection as Record<string, Record<string, unknown>>).corrected).toMatchObject({
|
||||
expect(
|
||||
(corrections[0]!.userCorrection as Record<string, Record<string, unknown>>).corrected,
|
||||
).toMatchObject({
|
||||
displayName: "Mellanmjölk",
|
||||
quantity: 1,
|
||||
unit: "LITER",
|
||||
@@ -203,7 +221,9 @@ describe("scan confirmation → ai_corrections", () => {
|
||||
.from(schema.aiCorrections)
|
||||
.where(eq(schema.aiCorrections.scanJobId, scanJobId));
|
||||
expect(corrections[0]!.imageS3Key).toBe("fridge-scans/test-image.jpg");
|
||||
expect((corrections[0]!.consentSnapshot as Record<string, string>).image_training).toBe("granted");
|
||||
expect((corrections[0]!.consentSnapshot as Record<string, string>).image_training).toBe(
|
||||
"granted",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not save image reference when image_training consent is denied", async () => {
|
||||
|
||||
@@ -40,7 +40,10 @@ interface BudgetSummary {
|
||||
export default function HomeScreen() {
|
||||
const inventory = useQuery({
|
||||
queryKey: ["inventory"],
|
||||
queryFn: () => api<{ items: InventoryItem[]; trustStatus?: "up_to_date" | "needs_check" | "uncertain" }>("/v1/inventory?limit=100"),
|
||||
queryFn: () =>
|
||||
api<{ items: InventoryItem[]; trustStatus?: "up_to_date" | "needs_check" | "uncertain" }>(
|
||||
"/v1/inventory?limit=100",
|
||||
),
|
||||
});
|
||||
const expiring = useQuery({
|
||||
queryKey: ["inventory-expiring"],
|
||||
|
||||
@@ -54,42 +54,42 @@ export default function RootLayout() {
|
||||
>
|
||||
<AnalyticsProvider>
|
||||
<StatusBar style="dark" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.background },
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/register" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/forgot-password" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/reset-password" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/verify-email" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="onboarding" options={{ headerShown: false, gestureEnabled: false }} />
|
||||
<Stack.Screen name="recipe/[id]" options={{ title: "" }} />
|
||||
<Stack.Screen
|
||||
name="cooking/[id]"
|
||||
options={{ title: "", presentation: "fullScreenModal" }}
|
||||
/>
|
||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
||||
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title") }} />
|
||||
<Stack.Screen name="shopping" options={{ title: t("shopping.title") }} />
|
||||
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title") }} />
|
||||
<Stack.Screen name="household" options={{ title: t("home.household") }} />
|
||||
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
|
||||
<Stack.Screen name="profile" options={{ title: t("profile.title") }} />
|
||||
<Stack.Screen
|
||||
name="paywall"
|
||||
options={{ title: t("paywall.title"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="log-meal"
|
||||
options={{ title: t("myday.logMeal"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen name="barcode" options={{ title: "Streckkod" }} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.background },
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/register" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/forgot-password" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/reset-password" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/verify-email" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="onboarding" options={{ headerShown: false, gestureEnabled: false }} />
|
||||
<Stack.Screen name="recipe/[id]" options={{ title: "" }} />
|
||||
<Stack.Screen
|
||||
name="cooking/[id]"
|
||||
options={{ title: "", presentation: "fullScreenModal" }}
|
||||
/>
|
||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
||||
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title") }} />
|
||||
<Stack.Screen name="shopping" options={{ title: t("shopping.title") }} />
|
||||
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title") }} />
|
||||
<Stack.Screen name="household" options={{ title: t("home.household") }} />
|
||||
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
|
||||
<Stack.Screen name="profile" options={{ title: t("profile.title") }} />
|
||||
<Stack.Screen
|
||||
name="paywall"
|
||||
options={{ title: t("paywall.title"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="log-meal"
|
||||
options={{ title: t("myday.logMeal"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen name="barcode" options={{ title: "Streckkod" }} />
|
||||
</Stack>
|
||||
</AnalyticsProvider>
|
||||
</PersistQueryClientProvider>
|
||||
|
||||
@@ -60,10 +60,10 @@ export default function CookingScreen() {
|
||||
|
||||
const cook = useMutation({
|
||||
mutationFn: (body: unknown) =>
|
||||
api<{ sessionId: string; mealBoxMutations?: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }> }>(
|
||||
`/v1/recipes/${id}/cook`,
|
||||
{ method: "POST", body },
|
||||
),
|
||||
api<{
|
||||
sessionId: string;
|
||||
mealBoxMutations?: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>;
|
||||
}>(`/v1/recipes/${id}/cook`, { method: "POST", body }),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||||
@@ -125,7 +125,9 @@ export default function CookingScreen() {
|
||||
<Card>
|
||||
<Heading>✅ {t("cooked.title")}</Heading>
|
||||
<Body>{recipe.titleSv}</Body>
|
||||
<Small>{t("cooked.portionsCooked")}: {portionsCooked}</Small>
|
||||
<Small>
|
||||
{t("cooked.portionsCooked")}: {portionsCooked}
|
||||
</Small>
|
||||
</Card>
|
||||
<Small>{t("mealbox.guidanceNote")}</Small>
|
||||
<Button
|
||||
@@ -230,17 +232,21 @@ export default function CookingScreen() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button label={t("common.skip")} variant="ghost" onPress={() =>
|
||||
cook.mutate({
|
||||
portionsCooked,
|
||||
mealBoxPortions,
|
||||
actualPortionsEaten: defaultEaten,
|
||||
leftoverEstimatePortions: defaultLeftovers,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
mealType: "dinner",
|
||||
})
|
||||
} />
|
||||
<Button
|
||||
label={t("common.skip")}
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
cook.mutate({
|
||||
portionsCooked,
|
||||
mealBoxPortions,
|
||||
actualPortionsEaten: defaultEaten,
|
||||
leftoverEstimatePortions: defaultLeftovers,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
mealType: "dinner",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button label={t("common.back")} variant="ghost" onPress={() => setFinishing(false)} />
|
||||
</Screen>
|
||||
);
|
||||
|
||||
@@ -75,8 +75,7 @@ export default function MealBoxesScreen() {
|
||||
{boxes.length === 0 && <EmptyState text={t("mealbox.empty")} />}
|
||||
{boxes.map((box) => {
|
||||
const urgent = box.recommendedUseBy <= today;
|
||||
const canUndo =
|
||||
!!box.cookingSessionId && new Date(box.createdAt).getTime() > cutoff;
|
||||
const canUndo = !!box.cookingSessionId && new Date(box.createdAt).getTime() > cutoff;
|
||||
return (
|
||||
<Card key={box.id}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
@@ -109,18 +108,14 @@ export default function MealBoxesScreen() {
|
||||
onPress={() => {
|
||||
const sessionId = box.cookingSessionId;
|
||||
if (!sessionId) return;
|
||||
Alert.alert(
|
||||
t("mealbox.undoConfirmTitle"),
|
||||
t("mealbox.undoConfirmBody"),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.undo"),
|
||||
style: "destructive",
|
||||
onPress: () => undo.mutate(sessionId),
|
||||
},
|
||||
],
|
||||
);
|
||||
Alert.alert(t("mealbox.undoConfirmTitle"), t("mealbox.undoConfirmBody"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.undo"),
|
||||
style: "destructive",
|
||||
onPress: () => undo.mutate(sessionId),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
@@ -5,8 +5,19 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body, Button, Card, ErrorView, Heading, Input,
|
||||
LoadingView, Row, Screen, Small, Spacer, Tag, Title,
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
ErrorView,
|
||||
Heading,
|
||||
Input,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
import { spacing } from "@/lib/theme";
|
||||
|
||||
@@ -17,9 +28,16 @@ import { spacing } from "@/lib/theme";
|
||||
*/
|
||||
const MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"] as const;
|
||||
|
||||
interface MealComponent { name: string; estimatedGrams: number | null; confidence: number }
|
||||
interface MealComponent {
|
||||
name: string;
|
||||
estimatedGrams: number | null;
|
||||
confidence: number;
|
||||
}
|
||||
interface ScanJob {
|
||||
id: string; status: string; scanType: string; error: string | null;
|
||||
id: string;
|
||||
status: string;
|
||||
scanType: string;
|
||||
error: string | null;
|
||||
result: {
|
||||
kcalRange?: { min: number; max: number; mostLikely: number } | null;
|
||||
components?: MealComponent[];
|
||||
@@ -97,7 +115,9 @@ export default function MealReviewScreen() {
|
||||
{kcal ? (
|
||||
<>
|
||||
<Title>≈ {Math.round(kcal.mostLikely)} kcal</Title>
|
||||
<Small>{Math.round(kcal.min)}–{Math.round(kcal.max)} kcal</Small>
|
||||
<Small>
|
||||
{Math.round(kcal.min)}–{Math.round(kcal.max)} kcal
|
||||
</Small>
|
||||
</>
|
||||
) : (
|
||||
<Body>{t("scan.meal.noEstimate")}</Body>
|
||||
|
||||
@@ -5,11 +5,7 @@ import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
onboardingStarted,
|
||||
onboardingStepCompleted,
|
||||
onboardingSkipped,
|
||||
} from "@app/analytics";
|
||||
import { onboardingStarted, onboardingStepCompleted, onboardingSkipped } from "@app/analytics";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
@@ -113,7 +109,11 @@ export default function OnboardingScreen() {
|
||||
precisionMode: mode,
|
||||
},
|
||||
});
|
||||
track(onboardingStepCompleted({ properties: { step: "a", goals, primaryGoal, precisionMode: mode } }));
|
||||
track(
|
||||
onboardingStepCompleted({
|
||||
properties: { step: "a", goals, primaryGoal, precisionMode: mode },
|
||||
}),
|
||||
);
|
||||
setOnboardingStep(res.step);
|
||||
setLayer("b");
|
||||
// Let user into the app – Step B will be shown contextually later
|
||||
|
||||
@@ -53,7 +53,11 @@ export default function PaywallScreen() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
track(paywallViewed({ properties: { plan: entitlements.data?.plan, status: entitlements.data?.status } }));
|
||||
track(
|
||||
paywallViewed({
|
||||
properties: { plan: entitlements.data?.plan, status: entitlements.data?.status },
|
||||
}),
|
||||
);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const trialDaysLeft =
|
||||
|
||||
@@ -47,11 +47,16 @@ export default function ReconciliationScreen() {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["reconciliation-candidates"],
|
||||
queryFn: () => api<{ candidates: Candidate[] }>("/v1/reconciliations/start", { method: "POST", body: {} }),
|
||||
queryFn: () =>
|
||||
api<{ candidates: Candidate[] }>("/v1/reconciliations/start", { method: "POST", body: {} }),
|
||||
});
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: (input: { itemId: string; action: Candidate["suggestedAction"]; quantity?: number }) =>
|
||||
mutationFn: (input: {
|
||||
itemId: string;
|
||||
action: Candidate["suggestedAction"];
|
||||
quantity?: number;
|
||||
}) =>
|
||||
api<{ itemId: string; action: string; quantity: number; verifiedByUser: boolean }>(
|
||||
`/v1/reconciliations/items/${input.itemId}/resolve`,
|
||||
{ method: "POST", body: { action: input.action, quantity: input.quantity } },
|
||||
@@ -100,9 +105,7 @@ export default function ReconciliationScreen() {
|
||||
<Card>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Heading>{candidate!.displayName}</Heading>
|
||||
<Body>
|
||||
{formatQuantity(candidate!.quantity, candidate!.unit)}
|
||||
</Body>
|
||||
<Body>{formatQuantity(candidate!.quantity, candidate!.unit)}</Body>
|
||||
</Row>
|
||||
<Small>{candidate!.locationName}</Small>
|
||||
<Spacer size={spacing.xs} />
|
||||
|
||||
@@ -39,7 +39,8 @@ export default function ScanDiffReviewScreen() {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["scan-diff", jobId],
|
||||
queryFn: () => api<{ rows: DiffRow[] }>(`/v1/scans/${jobId}/diff`, { method: "POST", body: {} }),
|
||||
queryFn: () =>
|
||||
api<{ rows: DiffRow[] }>(`/v1/scans/${jobId}/diff`, { method: "POST", body: {} }),
|
||||
});
|
||||
|
||||
const apply = useMutation({
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Linking,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Body, Button, Screen, Small, Title } from "@/components/ui";
|
||||
import type { TextStyle } from "react-native";
|
||||
import { acceptConsent } from "@/lib/api";
|
||||
@@ -55,10 +49,7 @@ export function ConsentScreen({ onAccepted }: ConsentScreenProps) {
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.md, paddingBottom: spacing.lg }}>
|
||||
<Pressable
|
||||
onPress={() => setChecked((v) => !v)}
|
||||
style={styles.checkboxRow}
|
||||
>
|
||||
<Pressable onPress={() => setChecked((v) => !v)} style={styles.checkboxRow}>
|
||||
<View style={[styles.box, checked && styles.boxChecked]}>
|
||||
{checked && <Text style={styles.checkmark}>✓</Text>}
|
||||
</View>
|
||||
|
||||
@@ -23,7 +23,13 @@ export function useFirstScanCoach() {
|
||||
return { visible, showIfNeeded, dismiss };
|
||||
}
|
||||
|
||||
export function FirstScanCoach({ visible, onDismiss }: { visible: boolean; onDismiss: () => void }) {
|
||||
export function FirstScanCoach({
|
||||
visible,
|
||||
onDismiss,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade">
|
||||
<View
|
||||
|
||||
@@ -2,12 +2,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState } from
|
||||
import { Platform } from "react-native";
|
||||
import Constants from "expo-constants";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
type AnalyticsEvent,
|
||||
type Tracker,
|
||||
createHttpSend,
|
||||
createTracker,
|
||||
} from "@app/analytics";
|
||||
import { type AnalyticsEvent, type Tracker, createHttpSend, createTracker } from "@app/analytics";
|
||||
import { API_BASE } from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
import { BRAND } from "./brand";
|
||||
|
||||
@@ -4,16 +4,9 @@
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
+126
-12
@@ -212,16 +212,27 @@ export const EVAL_CASES: EvalCase[] = [
|
||||
input: {
|
||||
scope: "user",
|
||||
scopeId: "user-1",
|
||||
events: [{ id: "evt-1", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} }],
|
||||
events: [
|
||||
{ id: "evt-1", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} },
|
||||
],
|
||||
existingMemoryKeys: [],
|
||||
},
|
||||
verify(output) {
|
||||
const o = output as TaskOutput<"UPDATE_USER_MEMORY">;
|
||||
const eventIds = new Set(["evt-1"]);
|
||||
return [
|
||||
check("alla minnen har kända sourceEventIds", o.memoryUpdates.every((u) => u.sourceEventIds.every((id) => eventIds.has(id)))),
|
||||
check("inga minnen utan sourceEventIds", o.memoryUpdates.every((u) => u.sourceEventIds.length > 0)),
|
||||
check("origin är observed eller ai_inferred", o.memoryUpdates.every((u) => u.origin === "observed" || u.origin === "ai_inferred")),
|
||||
check(
|
||||
"alla minnen har kända sourceEventIds",
|
||||
o.memoryUpdates.every((u) => u.sourceEventIds.every((id) => eventIds.has(id))),
|
||||
),
|
||||
check(
|
||||
"inga minnen utan sourceEventIds",
|
||||
o.memoryUpdates.every((u) => u.sourceEventIds.length > 0),
|
||||
),
|
||||
check(
|
||||
"origin är observed eller ai_inferred",
|
||||
o.memoryUpdates.every((u) => u.origin === "observed" || u.origin === "ai_inferred"),
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -232,14 +243,19 @@ export const EVAL_CASES: EvalCase[] = [
|
||||
input: {
|
||||
scope: "user",
|
||||
scopeId: "user-1",
|
||||
events: [{ id: "evt-sv", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} }],
|
||||
events: [
|
||||
{ id: "evt-sv", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} },
|
||||
],
|
||||
existingMemoryKeys: [],
|
||||
},
|
||||
verify(output) {
|
||||
const o = output as TaskOutput<"UPDATE_USER_MEMORY">;
|
||||
return [
|
||||
check("minst ett minne returneras", o.memoryUpdates.length >= 1),
|
||||
check("summarySv är på svenska", o.memoryUpdates.every((u) => /[åäöÅÄÖ]/.test(u.summarySv) || u.summarySv.length > 0)),
|
||||
check(
|
||||
"summarySv är på svenska",
|
||||
o.memoryUpdates.every((u) => /[åäöÅÄÖ]/.test(u.summarySv) || u.summarySv.length > 0),
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -253,14 +269,40 @@ export const EVAL_CASES: EvalCase[] = [
|
||||
cuisine: "swedish",
|
||||
tags: [],
|
||||
totalTimeMinutes: 30,
|
||||
nutritionPerPortion: { kcal: 550, proteinG: 45, carbsG: 50, fatG: 18, saturatedFatG: 6, fiberG: 6, sugarG: 4, saltG: 1.5 },
|
||||
nutritionPerPortion: {
|
||||
kcal: 550,
|
||||
proteinG: 45,
|
||||
carbsG: 50,
|
||||
fatG: 18,
|
||||
saturatedFatG: 6,
|
||||
fiberG: 6,
|
||||
sugarG: 4,
|
||||
saltG: 1.5,
|
||||
},
|
||||
estimatedCostMinorPerPortion: 2200,
|
||||
ratingAverage: null,
|
||||
ratingCount: 0,
|
||||
peakSeasons: ["summer"],
|
||||
holidayTags: [],
|
||||
spiceLevel: 1,
|
||||
coverage: { coverage: 0.85, matches: [], missing: [], expiringUsed: [{ canonicalIngredientId: "chicken", displayNameSv: "kycklingen", required: 400, unit: "GRAM", availableInUnit: 500, covered: true, optional: false, mostUrgentDaysLeft: 2, usesExpiringItem: true }] },
|
||||
coverage: {
|
||||
coverage: 0.85,
|
||||
matches: [],
|
||||
missing: [],
|
||||
expiringUsed: [
|
||||
{
|
||||
canonicalIngredientId: "chicken",
|
||||
displayNameSv: "kycklingen",
|
||||
required: 400,
|
||||
unit: "GRAM",
|
||||
availableInUnit: 500,
|
||||
covered: true,
|
||||
optional: false,
|
||||
mostUrgentDaysLeft: 2,
|
||||
usesExpiringItem: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
daysSinceLastCooked: 14,
|
||||
householdRating: 4.8,
|
||||
ingredientIds: ["chicken"],
|
||||
@@ -275,12 +317,84 @@ export const EVAL_CASES: EvalCase[] = [
|
||||
remainingProteinG: 60,
|
||||
remainingKcal: 800,
|
||||
personalizationEnabled: true,
|
||||
memoryItems: [{ id: "m1", userId: "u1", kind: "structured_fact", key: "favorite_cuisine_swedish", summarySv: "Gillar svensk mat", value: { favoriteCuisine: "swedish" }, origin: "user_stated", confidence: 1, verifiedByUser: true, paused: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }],
|
||||
memoryItems: [
|
||||
{
|
||||
id: "m1",
|
||||
userId: "u1",
|
||||
kind: "structured_fact",
|
||||
key: "favorite_cuisine_swedish",
|
||||
summarySv: "Gillar svensk mat",
|
||||
value: { favoriteCuisine: "swedish" },
|
||||
origin: "user_stated",
|
||||
confidence: 1,
|
||||
verifiedByUser: true,
|
||||
paused: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
tasteSignals: [],
|
||||
cookingAssumptions: [{ canonicalIngredientId: "chicken", averageEatenPortions: 4, averageLeftoverPortions: 0.5, observationCount: 5 }],
|
||||
cookingAssumptions: [
|
||||
{
|
||||
canonicalIngredientId: "chicken",
|
||||
averageEatenPortions: 4,
|
||||
averageLeftoverPortions: 0.5,
|
||||
observationCount: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
const whySv = buildWhy(candidate, context, { coverage: 0.85, expiry: 0.8, nutritionFit: 0.9, taste: 0.8, rating: 0.9, season: 1, holiday: 0, time: 1, budget: 0.6, variety: 1, weather: 0.5, craving: 0.5, memoryFit: 1, tasteFit: 0, cookingAssumptionFit: 0.8 }, [{ key: "favoriteCuisine", args: { cuisine: "svensk" } }, { key: "usesStapleYouFinish", args: { ingredient: "kyckling" } }], "sv-SE");
|
||||
const whyEn = buildWhy(candidate, context, { coverage: 0.85, expiry: 0.8, nutritionFit: 0.9, taste: 0.8, rating: 0.9, season: 1, holiday: 0, time: 1, budget: 0.6, variety: 1, weather: 0.5, craving: 0.5, memoryFit: 1, tasteFit: 0, cookingAssumptionFit: 0.8 }, [{ key: "favoriteCuisine", args: { cuisine: "Swedish" } }, { key: "usesStapleYouFinish", args: { ingredient: "chicken" } }], "en-US");
|
||||
const whySv = buildWhy(
|
||||
candidate,
|
||||
context,
|
||||
{
|
||||
coverage: 0.85,
|
||||
expiry: 0.8,
|
||||
nutritionFit: 0.9,
|
||||
taste: 0.8,
|
||||
rating: 0.9,
|
||||
season: 1,
|
||||
holiday: 0,
|
||||
time: 1,
|
||||
budget: 0.6,
|
||||
variety: 1,
|
||||
weather: 0.5,
|
||||
craving: 0.5,
|
||||
memoryFit: 1,
|
||||
tasteFit: 0,
|
||||
cookingAssumptionFit: 0.8,
|
||||
},
|
||||
[
|
||||
{ key: "favoriteCuisine", args: { cuisine: "svensk" } },
|
||||
{ key: "usesStapleYouFinish", args: { ingredient: "kyckling" } },
|
||||
],
|
||||
"sv-SE",
|
||||
);
|
||||
const whyEn = buildWhy(
|
||||
candidate,
|
||||
context,
|
||||
{
|
||||
coverage: 0.85,
|
||||
expiry: 0.8,
|
||||
nutritionFit: 0.9,
|
||||
taste: 0.8,
|
||||
rating: 0.9,
|
||||
season: 1,
|
||||
holiday: 0,
|
||||
time: 1,
|
||||
budget: 0.6,
|
||||
variety: 1,
|
||||
weather: 0.5,
|
||||
craving: 0.5,
|
||||
memoryFit: 1,
|
||||
tasteFit: 0,
|
||||
cookingAssumptionFit: 0.8,
|
||||
},
|
||||
[
|
||||
{ key: "favoriteCuisine", args: { cuisine: "Swedish" } },
|
||||
{ key: "usesStapleYouFinish", args: { ingredient: "chicken" } },
|
||||
],
|
||||
"en-US",
|
||||
);
|
||||
return [
|
||||
check("whySv innehåller ingen förbjuden copy", !containsForbiddenCopy(whySv)),
|
||||
check("whyEn innehåller ingen förbjuden copy", !containsForbiddenCopy(whyEn)),
|
||||
|
||||
@@ -36,14 +36,16 @@ interface ScanEvalCase {
|
||||
imageUrls: string[];
|
||||
locationType: "fridge" | "pantry";
|
||||
marketLocale: string;
|
||||
checks: (items: Array<{
|
||||
detectedName: string;
|
||||
brand: string | null;
|
||||
estimatedQuantity: number | null;
|
||||
unit: string | null;
|
||||
confidence: number;
|
||||
requiresConfirmation: boolean;
|
||||
}>) => { name: string; passed: boolean }[];
|
||||
checks: (
|
||||
items: Array<{
|
||||
detectedName: string;
|
||||
brand: string | null;
|
||||
estimatedQuantity: number | null;
|
||||
unit: string | null;
|
||||
confidence: number;
|
||||
requiresConfirmation: boolean;
|
||||
}>,
|
||||
) => { name: string; passed: boolean }[];
|
||||
}
|
||||
|
||||
const CASES: ScanEvalCase[] = [
|
||||
@@ -57,7 +59,10 @@ const CASES: ScanEvalCase[] = [
|
||||
return [
|
||||
{ name: "hittade mjölkprodukt", passed: !!milk },
|
||||
{ name: "konfidens > 0.5", passed: !!milk && milk.confidence > 0.5 },
|
||||
{ name: "kräver bekräftelse om låg konfidens", passed: !!milk && (milk.confidence >= 0.92 || milk.requiresConfirmation) },
|
||||
{
|
||||
name: "kräver bekräftelse om låg konfidens",
|
||||
passed: !!milk && (milk.confidence >= 0.92 || milk.requiresConfirmation),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -71,7 +76,10 @@ const CASES: ScanEvalCase[] = [
|
||||
return [
|
||||
{ name: "hittade konserverad produkt", passed: !!found },
|
||||
{ name: "konfidens > 0.5", passed: !!found && found.confidence > 0.5 },
|
||||
{ name: "minst ett item med confidence > 0.7", passed: items.some((i) => i.confidence > 0.7) },
|
||||
{
|
||||
name: "minst ett item med confidence > 0.7",
|
||||
passed: items.some((i) => i.confidence > 0.7),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -101,7 +109,9 @@ async function main() {
|
||||
apiKey,
|
||||
model: process.env.GEMINI_MODEL,
|
||||
timeoutMs: process.env.GEMINI_TIMEOUT_MS ? Number(process.env.GEMINI_TIMEOUT_MS) : 60_000,
|
||||
dailyBudgetUsd: process.env.GEMINI_DAILY_BUDGET_USD ? Number(process.env.GEMINI_DAILY_BUDGET_USD) : 10,
|
||||
dailyBudgetUsd: process.env.GEMINI_DAILY_BUDGET_USD
|
||||
? Number(process.env.GEMINI_DAILY_BUDGET_USD)
|
||||
: 10,
|
||||
budgetStore: new SilentBudgetStore(),
|
||||
});
|
||||
|
||||
@@ -153,8 +163,12 @@ async function main() {
|
||||
if (error) console.log(` fel: ${error}`);
|
||||
}
|
||||
|
||||
console.log(`\n[eval:scan] ${CASES.length} fall, ${totalChecks} kontroller, ${failedChecks} fallerade.`);
|
||||
console.log(`[eval:scan] Total latens: ${totalLatencyMs} ms, total kostnad: ~$${totalCostUsd.toFixed(6)}`);
|
||||
console.log(
|
||||
`\n[eval:scan] ${CASES.length} fall, ${totalChecks} kontroller, ${failedChecks} fallerade.`,
|
||||
);
|
||||
console.log(
|
||||
`[eval:scan] Total latens: ${totalLatencyMs} ms, total kostnad: ~$${totalCostUsd.toFixed(6)}`,
|
||||
);
|
||||
|
||||
if (failedChecks > 0) {
|
||||
console.log("[eval:scan] UNDERKÄND – åtgärda innan Skiva 1 går till fälttest.");
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
type S3ClientConfig,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { PutObjectCommand, S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
|
||||
import type { AamosTaskType } from "@app/ai-contracts";
|
||||
|
||||
export interface CaptureConsentFlags {
|
||||
|
||||
@@ -338,14 +338,18 @@ export async function processMemorySync(ctx: WorkerContext): Promise<number> {
|
||||
proposal.sourceEventIds.every((id) => eventIdSet.has(id));
|
||||
if (!hasEventSupport) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`UPDATE_USER_MEMORY: avvisar ${proposal.key} för ${userId}: saknar event-stöd.`);
|
||||
console.log(
|
||||
`UPDATE_USER_MEMORY: avvisar ${proposal.key} för ${userId}: saknar event-stöd.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// R1: ai_inferred ska ha låg konfidens.
|
||||
if (proposal.origin === "ai_inferred" && proposal.confidence > AI_INFERRED_MAX_CONFIDENCE) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`UPDATE_USER_MEMORY: avvisar ${proposal.key} för ${userId}: ai_inferred med för hög confidence.`);
|
||||
console.log(
|
||||
`UPDATE_USER_MEMORY: avvisar ${proposal.key} för ${userId}: ai_inferred med för hög confidence.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +334,6 @@ function tokenize(text: string): string[] {
|
||||
.filter((t) => t.length > 1);
|
||||
}
|
||||
|
||||
|
||||
function classifyScanError(error?: string | null): string {
|
||||
if (!error) return "unknown";
|
||||
const lower = error.toLowerCase();
|
||||
|
||||
@@ -11,7 +11,10 @@ describe("pushOpsSummaryToEoc", () => {
|
||||
beforeAll(async () => {
|
||||
await redis.set(
|
||||
"ops:summary:cache",
|
||||
JSON.stringify({ app: { app: "cibello", generated_at: new Date().toISOString() }, as_of: new Date().toISOString() }),
|
||||
JSON.stringify({
|
||||
app: { app: "cibello", generated_at: new Date().toISOString() },
|
||||
as_of: new Date().toISOString(),
|
||||
}),
|
||||
"EX",
|
||||
60,
|
||||
);
|
||||
@@ -131,7 +134,9 @@ describe("pushOpsSummaryToEoc", () => {
|
||||
}
|
||||
expect(board.tiles.find((t: { label: string }) => t.label === "MRR")?.tone).toBe("good");
|
||||
expect(board.tiles.find((t: { label: string }) => t.label === "Workers")?.tone).toBe("crit");
|
||||
expect(board.tiles.find((t: { label: string }) => t.label === "Allergen-brott")?.tone).toBe("crit");
|
||||
expect(board.tiles.find((t: { label: string }) => t.label === "Allergen-brott")?.tone).toBe(
|
||||
"crit",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -244,7 +244,13 @@ describe("UPDATE_USER_MEMORY hardening", () => {
|
||||
const aamos = {
|
||||
async runTask() {
|
||||
called = true;
|
||||
return { status: "ok", output: { memoryUpdates: [] }, costUsd: 0, inputTokens: 0, outputTokens: 0 } as AamosResult<"UPDATE_USER_MEMORY">;
|
||||
return {
|
||||
status: "ok",
|
||||
output: { memoryUpdates: [] },
|
||||
costUsd: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
} as AamosResult<"UPDATE_USER_MEMORY">;
|
||||
},
|
||||
async healthCheck() {
|
||||
return { ok: true };
|
||||
|
||||
@@ -13,10 +13,7 @@ describe.sequential("SEND_PROACTIVE_TIPS", () => {
|
||||
const emailBase = "proactive-tips-test";
|
||||
|
||||
async function cleanup() {
|
||||
const emails = [
|
||||
`${emailBase}-owner@example.invalid`,
|
||||
`${emailBase}-member@example.invalid`,
|
||||
];
|
||||
const emails = [`${emailBase}-owner@example.invalid`, `${emailBase}-member@example.invalid`];
|
||||
const users = await testDb.db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
@@ -24,7 +21,9 @@ describe.sequential("SEND_PROACTIVE_TIPS", () => {
|
||||
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.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, userId));
|
||||
await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, userId));
|
||||
}
|
||||
const households = await testDb.db
|
||||
@@ -32,20 +31,36 @@ describe.sequential("SEND_PROACTIVE_TIPS", () => {
|
||||
.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.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"));
|
||||
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() {
|
||||
@@ -68,7 +83,12 @@ describe.sequential("SEND_PROACTIVE_TIPS", () => {
|
||||
|
||||
const [household] = await testDb.db
|
||||
.insert(schema.households)
|
||||
.values({ name: "Proactive Tips Test", size: 2, locale: "sv-SE", inviteCode: "PROACTIVETEST" })
|
||||
.values({
|
||||
name: "Proactive Tips Test",
|
||||
size: 2,
|
||||
locale: "sv-SE",
|
||||
inviteCode: "PROACTIVETEST",
|
||||
})
|
||||
.returning();
|
||||
|
||||
await testDb.db.insert(schema.householdMembers).values([
|
||||
@@ -236,7 +256,9 @@ describe.sequential("SEND_PROACTIVE_TIPS", () => {
|
||||
await testDb.db
|
||||
.update(schema.userConsents)
|
||||
.set({ status: "revoked" })
|
||||
.where(and(eq(schema.userConsents.userId, ownerId), eq(schema.userConsents.kind, "notifications")));
|
||||
.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));
|
||||
|
||||
@@ -8,5 +8,6 @@ process.env.EMAIL_MODE = "log";
|
||||
process.env.S3_MODE = "mock";
|
||||
process.env.LOG_LEVEL = "error";
|
||||
|
||||
process.env.TEST_DATABASE_URL ||= "postgres://app_user:app_dev_password@localhost:5432/cibello_test";
|
||||
process.env.TEST_DATABASE_URL ||=
|
||||
"postgres://app_user:app_dev_password@localhost:5432/cibello_test";
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
|
||||
@@ -9,7 +9,10 @@ describe("BUILD_TRAINING_SAMPLE banks locally", () => {
|
||||
const email = "training-export-test@example.invalid";
|
||||
|
||||
async function cleanup() {
|
||||
const existing = await testDb.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.email, email));
|
||||
const existing = await testDb.db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.email, email));
|
||||
for (const u of existing) {
|
||||
await testDb.db.delete(schema.aiCorrections).where(eq(schema.aiCorrections.userId, u.id));
|
||||
await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id));
|
||||
@@ -63,7 +66,10 @@ describe("BUILD_TRAINING_SAMPLE banks locally", () => {
|
||||
taskType: "ANALYZE_FRIDGE_IMAGE",
|
||||
aiOutput: { raw: { items: [] } },
|
||||
proposal: { detectedName: "Mellanmjölk", confidence: 0.98 },
|
||||
userCorrection: { action: "accept", corrected: { displayName: "Mellanmjölk", quantity: 1, unit: "LITER" } },
|
||||
userCorrection: {
|
||||
action: "accept",
|
||||
corrected: { displayName: "Mellanmjölk", quantity: 1, unit: "LITER" },
|
||||
},
|
||||
imageS3Key: "fridge-scans/train.jpg",
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
promptVersion: "gemini-fridge-v1",
|
||||
|
||||
Reference in New Issue
Block a user