mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
af81827b14
commit
ed1dae4e99
2 changed files with 105 additions and 0 deletions
62
src/lib/tags/model.test.ts
Normal file
62
src/lib/tags/model.test.ts
Normal 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
43
src/lib/tags/model.ts
Normal 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 } };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue