From c7be73786b36c713a8bc428869024baf365c814c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:12:36 +0200 Subject: [PATCH] Improve coverage for image upload and schedule helper logic with focused unit tests (#551) Agent-Logs-Url: https://github.com/DanielVolz/medassist-ng/sessions/a5af7c91-2dd4-4a79-838e-dbb79fc08f6d Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> --- backend/src/test/image-upload.test.ts | 87 +++++++++++++++++++ .../test/features/schedule/formatters.test.ts | 76 ++++++++++++++++ .../features/schedule/interactions.test.ts | 35 ++++++++ 3 files changed, 198 insertions(+) create mode 100644 backend/src/test/image-upload.test.ts create mode 100644 frontend/src/test/features/schedule/formatters.test.ts create mode 100644 frontend/src/test/features/schedule/interactions.test.ts diff --git a/backend/src/test/image-upload.test.ts b/backend/src/test/image-upload.test.ts new file mode 100644 index 0000000..3d60ea2 --- /dev/null +++ b/backend/src/test/image-upload.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import sharp from "sharp"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getThumbFilename, + MAX_IMAGE_UPLOAD_BYTES, + removeImageFiles, + streamToBuffer, + writeOptimizedImageSet, +} from "../utils/image-upload"; + +describe("image-upload utils", () => { + const MOCK_TIMESTAMP_MS = 1_700_000_000_000; + const tempDirs: string[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("builds thumb filename with and without extension", () => { + expect(getThumbFilename("avatar.png")).toBe("avatar-thumb.webp"); + expect(getThumbFilename("avatar")).toBe("avatar-thumb.webp"); + }); + + it("removes original and thumb files when they exist", () => { + const imagesDir = mkdtempSync(join(tmpdir(), "medassist-image-upload-")); + tempDirs.push(imagesDir); + + const imageFilename = "profile.webp"; + const imagePath = join(imagesDir, imageFilename); + const thumbPath = join(imagesDir, getThumbFilename(imageFilename)); + writeFileSync(imagePath, Buffer.from("image")); + writeFileSync(thumbPath, Buffer.from("thumb")); + + removeImageFiles(imagesDir, imageFilename); + + expect(() => readFileSync(imagePath)).toThrow(); + expect(() => readFileSync(thumbPath)).toThrow(); + }); + + it("buffers stream chunks and rejects payloads above max size", async () => { + const stream = Readable.from([Buffer.from("hello"), Buffer.from("world")]); + await expect(streamToBuffer(stream)).resolves.toEqual(Buffer.from("helloworld")); + + const oversized = Readable.from([Buffer.alloc(MAX_IMAGE_UPLOAD_BYTES + 1)]); + await expect(streamToBuffer(oversized)).rejects.toThrow("IMAGE_TOO_LARGE"); + }); + + it("writes optimized full and thumbnail webp variants", async () => { + const imagesDir = mkdtempSync(join(tmpdir(), "medassist-image-upload-")); + tempDirs.push(imagesDir); + vi.spyOn(Date, "now").mockReturnValue(MOCK_TIMESTAMP_MS); + + const uploadBuffer = await sharp({ + create: { + width: 64, + height: 48, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png() + .toBuffer(); + + const result = await writeOptimizedImageSet(imagesDir, "med-42", uploadBuffer, { + maxEdgePx: 32, + thumbSizePx: 16, + }); + + expect(result.filename).toBe("med-42-1700000000000.webp"); + expect(result.thumbFilename).toBe("med-42-1700000000000-thumb.webp"); + + const optimizedMeta = await sharp(join(imagesDir, result.filename)).metadata(); + const thumbMeta = await sharp(join(imagesDir, result.thumbFilename)).metadata(); + expect(optimizedMeta.format).toBe("webp"); + expect(thumbMeta.format).toBe("webp"); + expect(Math.max(optimizedMeta.width ?? 0, optimizedMeta.height ?? 0)).toBeLessThanOrEqual(32); + expect(thumbMeta.width).toBe(16); + expect(thumbMeta.height).toBe(16); + }); +}); diff --git a/frontend/src/test/features/schedule/formatters.test.ts b/frontend/src/test/features/schedule/formatters.test.ts new file mode 100644 index 0000000..1e39fa5 --- /dev/null +++ b/frontend/src/test/features/schedule/formatters.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../../../features/schedule/formatters"; + +const t = (key: string, options?: Record): string => { + switch (key) { + case "form.packageAmountUnitMl": + return "ml"; + case "form.blisters.teaspoons": + return Number(options?.count) === 1 ? "teaspoon" : "teaspoons"; + case "form.blisters.tablespoons": + return Number(options?.count) === 1 ? "tablespoon" : "tablespoons"; + case "form.blisters.applications": + return Number(options?.count) === 1 ? "application" : "applications"; + case "common.pill": + return "pill"; + case "common.pills": + return "pills"; + case "common.pillsTotal": + return `${options?.count ?? 0} pills total`; + default: + return key; + } +}; + +describe("schedule formatters", () => { + it("formats liquid dose labels in base and converted units", () => { + expect(formatScheduleDoseUsageLabel({ packageType: "liquid_container" }, 0, t, "ml")).toBe("0 ml"); + expect(formatScheduleDoseUsageLabel({ packageType: "liquid_container" }, 2, t, "tsp")).toBe("2 teaspoons 10 ml"); + }); + + it("formats tube doses as applications by default and ml for liquid forms", () => { + expect(formatScheduleDoseUsageLabel({ packageType: "tube" }, 1, t)).toBe("1 application"); + expect(formatScheduleDoseUsageLabel({ packageType: "tube", medicationForm: "liquid" }, 3, t)).toBe("3 ml"); + }); + + it("formats liquid totals from dose units and mixed-unit conversion", () => { + expect( + formatScheduleTotalUsageLabel( + { packageType: "liquid_container" }, + 0, + t, + [ + { usage: 1, intakeUnit: "tsp" }, + { usage: 2, intakeUnit: "tsp" }, + ], + "ml" + ) + ).toBe("3 teaspoons 15 ml"); + + expect( + formatScheduleTotalUsageLabel( + { packageType: "liquid_container" }, + 0, + t, + [ + { usage: 1, intakeUnit: "tsp" }, + { usage: 1, intakeUnit: "tbsp" }, + ], + "ml" + ) + ).toBe("20 ml"); + }); + + it("falls back to total and non-liquid totals when dose list is not usable", () => { + expect( + formatScheduleTotalUsageLabel( + { packageType: "liquid_container" }, + 4, + t, + [{ usage: -1, intakeUnit: "ml" }], + "tbsp" + ) + ).toBe("4 tablespoons 60 ml"); + expect(formatScheduleTotalUsageLabel({ packageType: "blister" }, 3, t)).toBe("3 pills total"); + }); +}); diff --git a/frontend/src/test/features/schedule/interactions.test.ts b/frontend/src/test/features/schedule/interactions.test.ts new file mode 100644 index 0000000..3526e49 --- /dev/null +++ b/frontend/src/test/features/schedule/interactions.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + areAllDoseIdsTaken, + countTakenDoseIds, + resolveCollapsedState, + toggleDateInSet, +} from "../../../features/schedule/interactions"; + +describe("schedule interactions", () => { + it("toggles dates without mutating the original set", () => { + const previous = new Set(["2026-01-01"]); + const added = toggleDateInSet(previous, "2026-01-02"); + const removed = toggleDateInSet(added, "2026-01-01"); + + expect(previous).toEqual(new Set(["2026-01-01"])); + expect(added).toEqual(new Set(["2026-01-01", "2026-01-02"])); + expect(removed).toEqual(new Set(["2026-01-02"])); + }); + + it("resolves auto and manual collapsed states", () => { + expect(resolveCollapsedState(true, "2026-01-01", new Set(), new Set())).toBe(true); + expect(resolveCollapsedState(true, "2026-01-01", new Set(["2026-01-01"]), new Set())).toBe(false); + expect(resolveCollapsedState(false, "2026-01-01", new Set(), new Set(["2026-01-01"]))).toBe(true); + }); + + it("counts and checks taken dose ids", () => { + const taken = new Set(["a", "c"]); + const isDoseTaken = (doseId: string) => taken.has(doseId); + + expect(countTakenDoseIds(["a", "b", "c"], isDoseTaken)).toBe(2); + expect(areAllDoseIdsTaken(["a", "c"], isDoseTaken)).toBe(true); + expect(areAllDoseIdsTaken(["a", "b"], isDoseTaken)).toBe(false); + expect(areAllDoseIdsTaken([], isDoseTaken)).toBe(false); + }); +});