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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-13 15:50:09 +00:00
parent af81827b14
commit ed1dae4e99
2 changed files with 105 additions and 0 deletions

View file

@ -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" },
});
});
});

43
src/lib/tags/model.ts Normal file
View file

@ -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 } };
}