From ed1dae4e99bf0986f3a37b252a2d86747bf583fb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Jul 2026 15:50:09 +0000 Subject: [PATCH] feat(tags): pure Tag draft validation/normalisation (TDD) validateTagDraft: trims + requires a name, caps it at 30 chars, rejects a case-insensitive duplicate against the portfolio's existing tag names (the caller excludes the tag's own name on edit), and validates + lowercases a freeform hex colour (#rgb or #rrggbb). The DB's case-insensitive unique index remains the ultimate guard; this gives a friendly pre-check for the tag routes and settings UI. 8 behaviours, built red-green. ADR-0013. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/tags/model.test.ts | 62 ++++++++++++++++++++++++++++++++++++++ src/lib/tags/model.ts | 43 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/lib/tags/model.test.ts create mode 100644 src/lib/tags/model.ts diff --git a/src/lib/tags/model.test.ts b/src/lib/tags/model.test.ts new file mode 100644 index 00000000..1b012fc0 --- /dev/null +++ b/src/lib/tags/model.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { validateTagDraft } from "./model"; + +describe("validateTagDraft", () => { + it("accepts a valid name + hex colour, trimming the name and lowercasing the hex", () => { + expect(validateTagDraft({ name: " Void ", colour: "#7C3AED" }, [])).toEqual({ + ok: true, + tag: { name: "Void", colour: "#7c3aed" }, + }); + }); + + it("rejects a blank name (empty or whitespace-only)", () => { + expect(validateTagDraft({ name: " ", colour: "#7c3aed" }, [])).toEqual({ + ok: false, + error: "Tag name is required", + }); + }); + + it("rejects a name longer than 30 characters (after trimming)", () => { + const long = "a".repeat(31); + expect(validateTagDraft({ name: long, colour: "#7c3aed" }, [])).toEqual({ + ok: false, + error: "Tag name must be 30 characters or fewer", + }); + }); + + it("accepts a name of exactly 30 characters (boundary)", () => { + const name = "a".repeat(30); + expect(validateTagDraft({ name, colour: "#7c3aed" }, [])).toEqual({ + ok: true, + tag: { name, colour: "#7c3aed" }, + }); + }); + + it("rejects a name that duplicates an existing one, case-insensitively", () => { + expect( + validateTagDraft({ name: " VOID ", colour: "#7c3aed" }, ["Void", "Phase 1"]), + ).toEqual({ ok: false, error: "A tag with this name already exists" }); + }); + + it("accepts a name that clashes with no existing tag (edit excludes its own name)", () => { + // On edit the caller passes the OTHER tags' names, so keeping/​recasing the + // tag's own name is allowed. + expect( + validateTagDraft({ name: "Void", colour: "#7c3aed" }, ["Phase 1"]), + ).toEqual({ ok: true, tag: { name: "Void", colour: "#7c3aed" } }); + }); + + it("rejects a colour that is not a hex code", () => { + expect(validateTagDraft({ name: "Void", colour: "red" }, [])).toEqual({ + ok: false, + error: "Colour must be a hex code like #7c3aed", + }); + }); + + it("accepts a 3-digit hex shorthand, normalised to lowercase", () => { + expect(validateTagDraft({ name: "Void", colour: "#ABC" }, [])).toEqual({ + ok: true, + tag: { name: "Void", colour: "#abc" }, + }); + }); +}); diff --git a/src/lib/tags/model.ts b/src/lib/tags/model.ts new file mode 100644 index 00000000..49e8f39e --- /dev/null +++ b/src/lib/tags/model.ts @@ -0,0 +1,43 @@ +/** + * Pure Tag domain logic — validation + normalisation for creating/editing a + * portfolio Tag (ADR-0013). Kept free of DB access so it unit-tests cleanly and + * is shared by the tag routes. The DB's case-insensitive unique index is the + * ultimate guard against duplicate names; this gives a friendly pre-check. + */ + +export const MAX_TAG_NAME_LENGTH = 30; + +/** A hex colour: #rgb or #rrggbb (case-insensitive). */ +const HEX_COLOUR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; + +export interface TagDraft { + name: string; + colour: string; +} + +export type TagValidation = + | { ok: true; tag: TagDraft } + | { ok: false; error: string }; + +export function validateTagDraft( + input: TagDraft, + existingNames: string[], +): TagValidation { + const name = input.name.trim(); + if (name.length === 0) return { ok: false, error: "Tag name is required" }; + if (name.length > MAX_TAG_NAME_LENGTH) { + return { + ok: false, + error: `Tag name must be ${MAX_TAG_NAME_LENGTH} characters or fewer`, + }; + } + const lower = name.toLowerCase(); + if (existingNames.some((existing) => existing.trim().toLowerCase() === lower)) { + return { ok: false, error: "A tag with this name already exists" }; + } + const colour = input.colour.trim().toLowerCase(); + if (!HEX_COLOUR.test(colour)) { + return { ok: false, error: "Colour must be a hex code like #7c3aed" }; + } + return { ok: true, tag: { name, colour } }; +}