mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(mood): add hobbies and nutrition tag categories, persist bulk tagKeys
Deepen the structured mood-tag taxonomy and close a bulk-ingest data-loss gap the iOS client has been blocked on. Taxonomy (migration 0118, additive + idempotent, mirrors the 0101 seed): - New `hobbies` category: movies, reading, gaming, music, outdoors. - Nutrition-detail tags appended to the existing `health` category: fast_food, no_sweets, big_meal (alongside the original ate_well). - Lucide icon names seeded per row and registered in the client icon map; labelKeys added for every new category and tag across all six locales. Bulk tagKeys (data-loss fix): - `POST /api/mood-entries/bulk` previously Zod-stripped `tagKeys`, so structured taxonomy links were silently dropped on the adopt-on-pair backfill while the single-entry route persisted them correctly. Add `tagKeys` to the bulk entry schema and write the `MoodEntryTagLink` rows per entry via the shared `createTagLinks` helper — unknown keys are dropped against the catalog, the join insert skips duplicates so a re-posted batch stays idempotent. Tests cover the bulk tagKeys round-trip (links created, unknown keys ignored, over-long arrays rejected at the schema boundary) at the unit level and against real Postgres.
This commit is contained in:
1 parent
686bc1bf30
commit
14469a6de9
11 files changed
+296
-12
No files matched your search
+11
-2
@@ -510,7 +510,8 @@
|
||||
"sleep": "Schlaf",
|
||||
"health": "Gesundheit",
|
||||
"social": "Soziales",
|
||||
"work": "Arbeit"
|
||||
"work": "Arbeit",
|
||||
"hobbies": "Hobbys"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "glücklich",
|
||||
@@ -542,7 +543,15 @@
|
||||
"overtime": "Überstunden",
|
||||
"dayOff": "Feierabend",
|
||||
"travel": "Reise",
|
||||
"sickDay": "Krankheitstag"
|
||||
"sickDay": "Krankheitstag",
|
||||
"movies": "Filme",
|
||||
"reading": "gelesen",
|
||||
"gaming": "gezockt",
|
||||
"music": "Musik",
|
||||
"outdoors": "draußen",
|
||||
"fastFood": "Fast Food",
|
||||
"noSweets": "keine Süßigkeiten",
|
||||
"bigMeal": "üppige Mahlzeit"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
+11
-2
@@ -510,7 +510,8 @@
|
||||
"sleep": "Sleep",
|
||||
"health": "Health",
|
||||
"social": "Social",
|
||||
"work": "Work"
|
||||
"work": "Work",
|
||||
"hobbies": "Hobbies"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "happy",
|
||||
@@ -542,7 +543,15 @@
|
||||
"overtime": "overtime",
|
||||
"dayOff": "day off",
|
||||
"travel": "travel",
|
||||
"sickDay": "sick day"
|
||||
"sickDay": "sick day",
|
||||
"movies": "movies",
|
||||
"reading": "reading",
|
||||
"gaming": "gaming",
|
||||
"music": "music",
|
||||
"outdoors": "outdoors",
|
||||
"fastFood": "fast food",
|
||||
"noSweets": "no sweets",
|
||||
"bigMeal": "big meal"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
+11
-2
@@ -510,7 +510,8 @@
|
||||
"sleep": "Sueño",
|
||||
"health": "Salud",
|
||||
"social": "Social",
|
||||
"work": "Trabajo"
|
||||
"work": "Trabajo",
|
||||
"hobbies": "Aficiones"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "feliz",
|
||||
@@ -542,7 +543,15 @@
|
||||
"overtime": "horas extra",
|
||||
"dayOff": "día libre",
|
||||
"travel": "viaje",
|
||||
"sickDay": "día de baja"
|
||||
"sickDay": "día de baja",
|
||||
"movies": "películas",
|
||||
"reading": "lectura",
|
||||
"gaming": "videojuegos",
|
||||
"music": "música",
|
||||
"outdoors": "aire libre",
|
||||
"fastFood": "comida rápida",
|
||||
"noSweets": "sin dulces",
|
||||
"bigMeal": "comilona"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
+11
-2
@@ -510,7 +510,8 @@
|
||||
"sleep": "Sommeil",
|
||||
"health": "Santé",
|
||||
"social": "Social",
|
||||
"work": "Travail"
|
||||
"work": "Travail",
|
||||
"hobbies": "Loisirs"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "heureux",
|
||||
@@ -542,7 +543,15 @@
|
||||
"overtime": "heures sup",
|
||||
"dayOff": "jour de repos",
|
||||
"travel": "voyage",
|
||||
"sickDay": "jour de maladie"
|
||||
"sickDay": "jour de maladie",
|
||||
"movies": "films",
|
||||
"reading": "lecture",
|
||||
"gaming": "jeux vidéo",
|
||||
"music": "musique",
|
||||
"outdoors": "plein air",
|
||||
"fastFood": "fast-food",
|
||||
"noSweets": "sans sucreries",
|
||||
"bigMeal": "gros repas"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
+11
-2
@@ -510,7 +510,8 @@
|
||||
"sleep": "Sonno",
|
||||
"health": "Salute",
|
||||
"social": "Sociale",
|
||||
"work": "Lavoro"
|
||||
"work": "Lavoro",
|
||||
"hobbies": "Hobby"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "felice",
|
||||
@@ -542,7 +543,15 @@
|
||||
"overtime": "straordinari",
|
||||
"dayOff": "giorno libero",
|
||||
"travel": "viaggio",
|
||||
"sickDay": "giorno di malattia"
|
||||
"sickDay": "giorno di malattia",
|
||||
"movies": "film",
|
||||
"reading": "lettura",
|
||||
"gaming": "videogiochi",
|
||||
"music": "musica",
|
||||
"outdoors": "all'aperto",
|
||||
"fastFood": "fast food",
|
||||
"noSweets": "niente dolci",
|
||||
"bigMeal": "pasto abbondante"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
+11
-2
@@ -510,7 +510,8 @@
|
||||
"sleep": "Sen",
|
||||
"health": "Zdrowie",
|
||||
"social": "Społeczne",
|
||||
"work": "Praca"
|
||||
"work": "Praca",
|
||||
"hobbies": "Hobby"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "szczęśliwy",
|
||||
@@ -542,7 +543,15 @@
|
||||
"overtime": "nadgodziny",
|
||||
"dayOff": "dzień wolny",
|
||||
"travel": "podróż",
|
||||
"sickDay": "dzień chorobowy"
|
||||
"sickDay": "dzień chorobowy",
|
||||
"movies": "filmy",
|
||||
"reading": "czytanie",
|
||||
"gaming": "gry",
|
||||
"music": "muzyka",
|
||||
"outdoors": "na dworze",
|
||||
"fastFood": "fast food",
|
||||
"noSweets": "bez słodyczy",
|
||||
"bigMeal": "obfity posiłek"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- v1.12.0 — deepen the structured mood-tag taxonomy.
|
||||
--
|
||||
-- Purely additive on top of migration 0101's catalog: one new `hobbies`
|
||||
-- category (movies / reading / gaming / music / outdoors) plus a set of
|
||||
-- nutrition-detail tags appended to the existing `health` category
|
||||
-- (`fast_food` / `no_sweets` / `big_meal`, alongside the original
|
||||
-- `ate_well`). No table changes, no per-user data touched — this only
|
||||
-- seeds new global catalog rows.
|
||||
--
|
||||
-- `label_key` columns carry an i18n message key (resolved client-side
|
||||
-- against all six locales), same convention as the original seed. The
|
||||
-- inserts use deterministic ids + ON CONFLICT upsert by `key` so a
|
||||
-- re-run is idempotent, mirroring 0101 exactly.
|
||||
|
||||
-- ─── New category: hobbies ───────────────────────────────────────────
|
||||
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mtc_hobbies', 'hobbies', 'mood.tagCategory.hobbies', 'Palette', 5, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_movies', 'mtc_hobbies', 'movies', 'mood.tag.movies', 'Film', 0, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_reading', 'mtc_hobbies', 'reading', 'mood.tag.reading', 'BookOpen', 1, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_gaming', 'mtc_hobbies', 'gaming', 'mood.tag.gaming', 'Gamepad2', 2, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_music', 'mtc_hobbies', 'music', 'mood.tag.music', 'Music', 3, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_outdoors', 'mtc_hobbies', 'outdoors', 'mood.tag.outdoors', 'Trees', 4, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
-- ─── Nutrition-detail tags appended to the existing `health` category ─
|
||||
-- Mirrors the existing `ate_well` (sort_order 1) under `mtc_health`; the
|
||||
-- original health tags occupy sort_order 0-4, so these slot in at 5-7.
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_fast_food', 'mtc_health', 'fast_food', 'mood.tag.fastFood', 'Pizza', 5, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_no_sweets', 'mtc_health', 'no_sweets', 'mood.tag.noSweets', 'CandyOff', 6, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_big_meal', 'mtc_health', 'big_meal', 'mood.tag.bigMeal', 'UtensilsCrossed', 7, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
@@ -15,6 +15,14 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/mood/tag-links", () => ({
|
||||
createTagLinks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/moodlog/push", () => ({
|
||||
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -52,6 +60,8 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -71,6 +81,15 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(createTagLinks).mockResolvedValue(undefined);
|
||||
// `vi.resetAllMocks()` above wipes the factory default, so the
|
||||
// best-effort reverse-sync push must be re-stubbed to a resolved
|
||||
// promise — the route calls `.catch()` on its return value.
|
||||
vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({
|
||||
pushed: 0,
|
||||
skipped: 0,
|
||||
status: "skipped",
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -153,3 +172,55 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
|
||||
id: "entry-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("persists structured tag links for an entry carrying tagKeys", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
tagKeys: ["movies", "gaming"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(createTagLinks).toHaveBeenCalledTimes(1);
|
||||
expect(createTagLinks).toHaveBeenCalledWith("entry-1", ["movies", "gaming"]);
|
||||
});
|
||||
|
||||
it("skips the tag-link write when an entry sends no tagKeys", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(createTagLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips an over-long tagKeys array at the schema boundary (422)", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
tagKeys: Array.from({ length: 31 }, (_, i) => `k${i}`),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(createTagLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -55,6 +56,18 @@ const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
const bulkEntrySchema = z.object({
|
||||
mood: moodLevelEnum,
|
||||
tags: z.array(z.string().max(50)).max(20).optional(),
|
||||
/**
|
||||
* v1.12.0 — structured-tag keys from the catalog (`mood_tags.key`),
|
||||
* mirroring the single-entry `POST /api/mood-entries` contract.
|
||||
* Without this the bulk path Zod-stripped the field, so iOS-sent
|
||||
* taxonomy links were silently dropped on the adopt-on-pair backfill.
|
||||
* The server resolves each key to a `MoodTag` row and writes the
|
||||
* `MoodEntryTagLink` join; unknown keys are dropped silently (the
|
||||
* catalog is the source of truth). Bounds match the single-entry
|
||||
* `structuredTagKeys` schema so one entry can't fan out an unbounded
|
||||
* link set.
|
||||
*/
|
||||
tagKeys: z.array(z.string().max(60)).max(30).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
source: moodSourceEnum.optional().default("MANUAL"),
|
||||
@@ -204,6 +217,17 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.12.0 — persist structured-tag links, mirroring the
|
||||
// single-entry `createTagLinks` path. Additive + idempotent:
|
||||
// `createTagLinks` resolves keys against the catalog (dropping
|
||||
// unknown keys) and `skipDuplicates` on the join insert keeps a
|
||||
// re-posted entry from minting duplicate links. Runs for both
|
||||
// fresh and re-posted (upserted) rows so a backfill that adds tag
|
||||
// keys on a second pass still lands them.
|
||||
if (entry.tagKeys && entry.tagKeys.length > 0) {
|
||||
await createTagLinks(result.id, entry.tagKeys);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
duplicates += 1;
|
||||
results.push({ index: i, status: "duplicate", id: result.id });
|
||||
|
||||
@@ -2,16 +2,20 @@ import {
|
||||
AlertTriangle,
|
||||
Apple,
|
||||
BedDouble,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Briefcase,
|
||||
CandyOff,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudMoon,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
Film,
|
||||
Flame,
|
||||
Footprints,
|
||||
Frown,
|
||||
Gamepad2,
|
||||
GlassWater,
|
||||
HandHeart,
|
||||
Heart,
|
||||
@@ -22,14 +26,19 @@ import {
|
||||
Meh,
|
||||
Moon,
|
||||
MoonStar,
|
||||
Music,
|
||||
Palette,
|
||||
PartyPopper,
|
||||
Pizza,
|
||||
Plane,
|
||||
Smile,
|
||||
Tag,
|
||||
Thermometer,
|
||||
ThumbsUp,
|
||||
Trees,
|
||||
User,
|
||||
Users,
|
||||
UtensilsCrossed,
|
||||
Wine,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
@@ -46,16 +55,20 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
|
||||
AlertTriangle,
|
||||
Apple,
|
||||
BedDouble,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Briefcase,
|
||||
CandyOff,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudMoon,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
Film,
|
||||
Flame,
|
||||
Footprints,
|
||||
Frown,
|
||||
Gamepad2,
|
||||
GlassWater,
|
||||
HandHeart,
|
||||
Heart,
|
||||
@@ -66,13 +79,18 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
|
||||
Meh,
|
||||
Moon,
|
||||
MoonStar,
|
||||
Music,
|
||||
Palette,
|
||||
PartyPopper,
|
||||
Pizza,
|
||||
Plane,
|
||||
Smile,
|
||||
Thermometer,
|
||||
ThumbsUp,
|
||||
Trees,
|
||||
User,
|
||||
Users,
|
||||
UtensilsCrossed,
|
||||
Wine,
|
||||
Zap,
|
||||
};
|
||||
|
||||
@@ -153,6 +153,61 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
|
||||
expect(stored[0].score).toBe(5);
|
||||
});
|
||||
|
||||
it("persists structured tagKeys as MoodEntryTagLink rows (v1.12.0)", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
// Two valid catalog keys (one from the new hobbies category,
|
||||
// one nutrition-detail key) plus one unknown key that must be
|
||||
// dropped silently.
|
||||
tagKeys: ["movies", "fast_food", "not_a_real_tag"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { data: { inserted: number } };
|
||||
expect(json.data.inserted).toBe(1);
|
||||
|
||||
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
const links = await getPrismaClient().moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: entry.id },
|
||||
select: { moodTag: { select: { key: true } } },
|
||||
});
|
||||
const keys = links.map((l) => l.moodTag.key).sort();
|
||||
// The unknown key is dropped; the two catalog keys round-trip.
|
||||
expect(keys).toEqual(["fast_food", "movies"]);
|
||||
});
|
||||
|
||||
it("ignores tagKeys that resolve to no catalog rows", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T09:00:00.000Z",
|
||||
tagKeys: ["totally_unknown_key"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
const linkCount = await getPrismaClient().moodEntryTagLink.count({
|
||||
where: { moodEntryId: entry.id },
|
||||
});
|
||||
expect(linkCount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an over-cap batch with 422 and the documented error code", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const entries = Array.from({ length: 501 }, (_, i) => ({
|
||||
|
||||
Reference in new issue
Block a user