mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Add the Category -> Tag taxonomy the mood surface was missing and the free-text note the web form could never write, then surface both on the Insights mood page. Schema (migration 0101): a global MoodTagCategory -> MoodTag catalog plus a MoodEntryTagLink join, seeded with a default five-category set (i18n-keyed labels, Lucide icons). Purely additive — the legacy flat MoodEntry.tags JSON column, the GLP-1 quick-tags, and the Telegram / MoodLog sync are untouched; an entry can carry both axes. Capture: the mood-logging form and the edit sheet gain a Textarea for the note (model + API already accepted it) and an icon-chip picker over the taxonomy catalog (GET /api/mood/tags). Create / update resolve the picked keys to links; unknown keys drop silently against the catalog. Insights: a structured-tag breakdown (icon + per-tag avg-mood lift) and a notes timeline join the existing distribution / weekday / flat-tag sections. The aggregate extension also fixes the flat-tag axis, which read the raw JSON string and so never populated. i18n across all six locales. Tests cover the taxonomy aggregation, the note round-trip, the structured-tag schema, and the notes timeline.
89 lines
2.5 KiB
TypeScript
89 lines
2.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
moodLogCredentialsSchema,
|
|
createMoodEntrySchema,
|
|
updateMoodEntrySchema,
|
|
} from "../moodlog";
|
|
|
|
describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => {
|
|
const base = { mood: "GUT", moodLoggedAt: "2026-06-01T12:00:00.000Z" };
|
|
|
|
it("round-trips the note field", () => {
|
|
const r = createMoodEntrySchema.safeParse({ ...base, note: "felt great" });
|
|
expect(r.success).toBe(true);
|
|
if (r.success) expect(r.data.note).toBe("felt great");
|
|
});
|
|
|
|
it("accepts structured tagKeys alongside flat tags", () => {
|
|
const r = createMoodEntrySchema.safeParse({
|
|
...base,
|
|
tags: ["nausea"],
|
|
tagKeys: ["happy", "worked_out"],
|
|
});
|
|
expect(r.success).toBe(true);
|
|
if (r.success) {
|
|
expect(r.data.tagKeys).toEqual(["happy", "worked_out"]);
|
|
expect(r.data.tags).toEqual(["nausea"]);
|
|
}
|
|
});
|
|
|
|
it("rejects an over-long note and an over-large tagKeys set", () => {
|
|
expect(
|
|
createMoodEntrySchema.safeParse({ ...base, note: "x".repeat(501) }).success,
|
|
).toBe(false);
|
|
expect(
|
|
createMoodEntrySchema.safeParse({
|
|
...base,
|
|
tagKeys: Array.from({ length: 31 }, (_, i) => `t${i}`),
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("allows update to clear tagKeys with null", () => {
|
|
const r = updateMoodEntrySchema.safeParse({ tagKeys: null });
|
|
expect(r.success).toBe(true);
|
|
if (r.success) expect(r.data.tagKeys).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("moodLogCredentialsSchema SSRF guard", () => {
|
|
it("accepts a public moodLog URL", () => {
|
|
const r = moodLogCredentialsSchema.safeParse({
|
|
url: "https://moodlog.app",
|
|
apiKey: "k".repeat(40),
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("rejects RFC1918 URLs", () => {
|
|
for (const url of [
|
|
"http://10.0.0.1",
|
|
"http://192.168.1.1",
|
|
"http://172.16.0.1",
|
|
"http://127.0.0.1",
|
|
]) {
|
|
const r = moodLogCredentialsSchema.safeParse({
|
|
url,
|
|
apiKey: "k".repeat(40),
|
|
});
|
|
expect(r.success, `expected reject for ${url}`).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("rejects link-local (cloud metadata) URLs", () => {
|
|
const r = moodLogCredentialsSchema.safeParse({
|
|
url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
|
apiKey: "k".repeat(40),
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects localhost", () => {
|
|
const r = moodLogCredentialsSchema.safeParse({
|
|
url: "http://localhost:8080",
|
|
apiKey: "k".repeat(40),
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
});
|