From d31822c7dc526e1fb6f1624f9e8cda690839897b Mon Sep 17 00:00:00 2001 From: The Joel Date: Mon, 20 Jul 2026 09:55:31 +0100 Subject: [PATCH 1/5] Add execution contract for department-labels tool --- tools/v2/team/department-labels/CONTRACT.md | 89 +++++++++++ .../fixtures/execution.fixtures.ts | 111 +++++++++++++ tools/v2/team/department-labels/index.ts | 27 ++++ .../services/execution.service.ts | 150 ++++++++++++++++++ .../team/department-labels/services/index.ts | 9 ++ .../tests/execution.service.test.ts | 125 +++++++++++++++ .../team/department-labels/types/contract.ts | 68 ++++++++ .../v2/team/department-labels/types/index.ts | 10 ++ .../team/department-labels/vitest.config.ts | 10 ++ 9 files changed, 599 insertions(+) create mode 100644 tools/v2/team/department-labels/CONTRACT.md create mode 100644 tools/v2/team/department-labels/fixtures/execution.fixtures.ts create mode 100644 tools/v2/team/department-labels/index.ts create mode 100644 tools/v2/team/department-labels/services/execution.service.ts create mode 100644 tools/v2/team/department-labels/services/index.ts create mode 100644 tools/v2/team/department-labels/tests/execution.service.test.ts create mode 100644 tools/v2/team/department-labels/types/contract.ts create mode 100644 tools/v2/team/department-labels/types/index.ts create mode 100644 tools/v2/team/department-labels/vitest.config.ts diff --git a/tools/v2/team/department-labels/CONTRACT.md b/tools/v2/team/department-labels/CONTRACT.md new file mode 100644 index 000000000..875dfade9 --- /dev/null +++ b/tools/v2/team/department-labels/CONTRACT.md @@ -0,0 +1,89 @@ +# Department Labels Execution Contract + +This is the stable, backend-facing contract for creating department labels. +It has no React, DOM, styling, routing, transport, or database dependency. + +## Entry point + +```ts +import { + departmentLabelsService, + createDepartmentLabelsService, +} from "./tools/v2/team/department-labels"; + +const result = await departmentLabelsService.execute(input); +``` + +The default service creates normalized department labels in memory. Applications +that need persistence construct a service with a `DepartmentLabelsRepository`: + +```ts +const service = createDepartmentLabelsService({ repository }); +const result = await service.execute(input); +``` + +The optional `generateId` and `now` dependencies make IDs and timestamps +replaceable for backend integrations and deterministic tests. + +## Input: `DepartmentLabelsInput` + +| Field | Type | Required | Contract | +| --------------- | ------------------------ | -------- | -------------------------------------------- | +| `createdBy` | `string` | yes | Non-empty creator identity. | +| `labels` | `DepartmentLabelInput[]` | yes | One or more labels, executed in array order. | +| `correlationId` | `string` | no | Opaque value propagated to the output. | + +Each label accepts an optional `id`, a non-empty `name`, a non-empty +`departmentCode`, an optional hex `color` (defaults to `#3B82F6`), and an +optional `description`. Caller-supplied label IDs must be unique in the set. +Department codes must be unique across all labels. + +## Output: `DepartmentLabelsResult` + +The result is a discriminated union: + +```ts +type DepartmentLabelsResult = + | { ok: true; data: DepartmentLabels } + | { ok: false; error: DepartmentLabelsError }; +``` + +A successful `DepartmentLabels` contains a generated labels ID, normalized +input fields, an ISO-8601 `createdAt`, and ordered labels. Each label has an +ID, a zero-based `order`, a unique department code, and a resolved color. + +Use `ok` to narrow the result. Error messages are diagnostic and may change; +consumers must use the stable `error.code` for control flow. + +## Error codes + +| Code | Meaning | +| ---------------------- | ------------------------------------------------------------------ | +| `INVALID_INPUT` | A required scalar, label, or field value is missing or invalid. | +| `DUPLICATE_LABEL_ID` | Two labels use the same caller-supplied ID. | +| `DUPLICATE_DEPARTMENT` | Two labels use the same department code. | +| `INVALID_COLOR_FORMAT` | The color is not a valid hex color code (e.g., #FF5733). | +| `PERSISTENCE_FAILED` | The injected repository rejected or threw while saving. | +| `INTERNAL_ERROR` | An unexpected clock, ID generation, or execution failure occurred. | + +Input-specific errors include a dot-path `field`, such as +`labels.0.color`. + +## Service boundary + +The executor owns contract validation, normalization, label ordering, ID and +timestamp assignment, color defaulting, and mapping expected failures to typed +results. + +The caller owns authentication and authorization, network transport, database +transactions, retries, presentation, and workflow execution after the labels are +created. Persistence is available only through the minimal +`DepartmentLabelsRepository.save(labels)` boundary. Expected failures are +returned, not thrown. + +## Fixtures + +`fixtures/execution.fixtures.ts` exports a successful three-label input plus +failure fixtures for empty labels, duplicate label IDs, duplicate department +codes, invalid color format, missing createdBy, missing label name, missing +department code, and a failing repository. diff --git a/tools/v2/team/department-labels/fixtures/execution.fixtures.ts b/tools/v2/team/department-labels/fixtures/execution.fixtures.ts new file mode 100644 index 000000000..88a81265c --- /dev/null +++ b/tools/v2/team/department-labels/fixtures/execution.fixtures.ts @@ -0,0 +1,111 @@ +import type { DepartmentLabelsInput } from "../types/contract"; + +export const successfulLabelsInput: DepartmentLabelsInput = { + createdBy: "admin@example.com", + correlationId: "request-123", + labels: [ + { + id: "finance-label", + name: "Finance", + departmentCode: "FIN", + color: "#10B981", + description: "Finance department for financial operations", + }, + { + id: "engineering-label", + name: "Engineering", + departmentCode: "ENG", + color: "#3B82F6", + description: "Engineering department for technical development", + }, + { + id: "hr-label", + name: "Human Resources", + departmentCode: "HR", + color: "#F59E0B", + }, + ], +}; + +export const missingLabelsInput: DepartmentLabelsInput = { + ...successfulLabelsInput, + labels: [], +}; + +export const duplicateLabelIdInput: DepartmentLabelsInput = { + ...successfulLabelsInput, + labels: [ + { + id: "duplicate-id", + name: "Finance", + departmentCode: "FIN", + color: "#10B981", + }, + { + id: "duplicate-id", + name: "Engineering", + departmentCode: "ENG", + color: "#3B82F6", + }, + ], +}; + +export const duplicateDepartmentInput: DepartmentLabelsInput = { + ...successfulLabelsInput, + labels: [ + { + name: "Finance", + departmentCode: "FIN", + color: "#10B981", + }, + { + name: "Financial Planning", + departmentCode: "FIN", + color: "#3B82F6", + }, + ], +}; + +export const invalidColorFormatInput: DepartmentLabelsInput = { + ...successfulLabelsInput, + labels: [ + { + name: "Finance", + departmentCode: "FIN", + color: "invalid-color", + }, + ], +}; + +export const missingCreatedByInput: DepartmentLabelsInput = { + ...successfulLabelsInput, + createdBy: "", +}; + +export const missingLabelNameInput: DepartmentLabelsInput = { + ...successfulLabelsInput, + labels: [ + { + name: "", + departmentCode: "FIN", + color: "#10B981", + }, + ], +}; + +export const missingDepartmentCodeInput: DepartmentLabelsInput = { + ...successfulLabelsInput, + labels: [ + { + name: "Finance", + departmentCode: "", + color: "#10B981", + }, + ], +}; + +export const failingRepository = { + async save(): Promise { + throw new Error("Fixture persistence outage"); + }, +}; diff --git a/tools/v2/team/department-labels/index.ts b/tools/v2/team/department-labels/index.ts new file mode 100644 index 000000000..1a3431b11 --- /dev/null +++ b/tools/v2/team/department-labels/index.ts @@ -0,0 +1,27 @@ +export { departmentLabelsService, createDepartmentLabelsService } from "./services"; +export type { + DepartmentLabelsDependencies, + DepartmentLabelsService, + DepartmentLabelsRepository, +} from "./services"; +export type { + DepartmentLabels, + DepartmentLabelsInput, + DepartmentLabelsResult, + DepartmentLabelsError, + DepartmentLabelsErrorCode, + DepartmentLabel, + DepartmentLabelInput, + ExecuteDepartmentLabels, +} from "./types"; +export { + duplicateDepartmentInput, + duplicateLabelIdInput, + failingRepository, + invalidColorFormatInput, + missingCreatedByInput, + missingDepartmentCodeInput, + missingLabelNameInput, + missingLabelsInput, + successfulLabelsInput, +} from "./fixtures/execution.fixtures"; diff --git a/tools/v2/team/department-labels/services/execution.service.ts b/tools/v2/team/department-labels/services/execution.service.ts new file mode 100644 index 000000000..bcc54e417 --- /dev/null +++ b/tools/v2/team/department-labels/services/execution.service.ts @@ -0,0 +1,150 @@ +import type { + DepartmentLabels, + DepartmentLabelsInput, + DepartmentLabelsResult, + DepartmentLabelsErrorCode, + DepartmentLabel, +} from "../types/contract"; + +/** Optional persistence boundary. Transport and storage details stay outside this tool. */ +export interface DepartmentLabelsRepository { + save(labels: DepartmentLabels): Promise; +} + +export interface DepartmentLabelsDependencies { + repository?: DepartmentLabelsRepository; + generateId?: () => string; + now?: () => Date; +} + +function failure( + code: DepartmentLabelsErrorCode, + message: string, + field?: string, +): DepartmentLabelsResult { + return { ok: false, error: { code, message, ...(field ? { field } : {}) } }; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isValidHexColor(value: string): boolean { + return /^#[0-9A-Fa-f]{6}$/.test(value); +} + +function validateInput(input: DepartmentLabelsInput): DepartmentLabelsResult | undefined { + if (!nonEmptyString(input?.createdBy)) { + return failure("INVALID_INPUT", "createdBy must be a non-empty string", "createdBy"); + } + + if (!Array.isArray(input.labels) || input.labels.length === 0) { + return failure("INVALID_INPUT", "labels must contain at least one label", "labels"); + } + + const labelIds = new Set(); + const departmentCodes = new Set(); + + for (const [index, label] of input.labels.entries()) { + const path = `labels.${index}`; + if (!label || !nonEmptyString(label.name)) { + return failure("INVALID_INPUT", "label name must be a non-empty string", `${path}.name`); + } + if (!nonEmptyString(label.departmentCode)) { + return failure( + "INVALID_INPUT", + "departmentCode must be a non-empty string", + `${path}.departmentCode`, + ); + } + if (label.id !== undefined && !nonEmptyString(label.id)) { + return failure("INVALID_INPUT", "label id must be a non-empty string", `${path}.id`); + } + if (label.id && labelIds.has(label.id)) { + return failure("DUPLICATE_LABEL_ID", `label id "${label.id}" is duplicated`, `${path}.id`); + } + if (label.id) labelIds.add(label.id); + + if (departmentCodes.has(label.departmentCode)) { + return failure( + "DUPLICATE_DEPARTMENT", + `departmentCode "${label.departmentCode}" is duplicated`, + `${path}.departmentCode`, + ); + } + departmentCodes.add(label.departmentCode); + + if (label.color !== undefined && !isValidHexColor(label.color)) { + return failure( + "INVALID_COLOR_FORMAT", + "color must be a valid hex color code (e.g., #FF5733)", + `${path}.color`, + ); + } + + if (label.description !== undefined && typeof label.description !== "string") { + return failure("INVALID_INPUT", "description must be a string", `${path}.description`); + } + } +} + +function defaultGenerateId(): string { + return globalThis.crypto?.randomUUID?.() ?? `labels-${Date.now()}-${Math.random()}`; +} + +function defaultColor(): string { + return "#3B82F6"; // Default blue color +} + +/** + * Creates a non-UI executor with replaceable clock, id generation, and storage. + */ +export function createDepartmentLabelsService( + dependencies: DepartmentLabelsDependencies = {}, +) { + const generateId = dependencies.generateId ?? defaultGenerateId; + const now = dependencies.now ?? (() => new Date()); + + async function execute(input: DepartmentLabelsInput): Promise { + try { + const validationFailure = validateInput(input); + if (validationFailure) return validationFailure; + + const labels: DepartmentLabel[] = input.labels.map((label, order) => ({ + id: label.id ?? generateId(), + name: label.name.trim(), + departmentCode: label.departmentCode.trim(), + color: label.color ?? defaultColor(), + description: label.description?.trim(), + order, + })); + + const departmentLabels: DepartmentLabels = { + id: generateId(), + createdBy: input.createdBy.trim(), + createdAt: now().toISOString(), + labels, + ...(input.correlationId !== undefined ? { correlationId: input.correlationId } : {}), + }; + + if (dependencies.repository) { + try { + await dependencies.repository.save(departmentLabels); + } catch { + return failure("PERSISTENCE_FAILED", "The department labels could not be persisted"); + } + } + + return { ok: true, data: departmentLabels }; + } catch { + return failure("INTERNAL_ERROR", "Department labels execution failed unexpectedly"); + } + } + + return { execute }; +} + +/** Default backend-facing entry point. It builds without assuming a persistence backend. */ +export const departmentLabelsService = createDepartmentLabelsService(); + +export type DepartmentLabelsService = ReturnType; diff --git a/tools/v2/team/department-labels/services/index.ts b/tools/v2/team/department-labels/services/index.ts new file mode 100644 index 000000000..2c7e68cc7 --- /dev/null +++ b/tools/v2/team/department-labels/services/index.ts @@ -0,0 +1,9 @@ +export { + departmentLabelsService, + createDepartmentLabelsService, +} from "./execution.service"; +export type { + DepartmentLabelsDependencies, + DepartmentLabelsService, + DepartmentLabelsRepository, +} from "./execution.service"; diff --git a/tools/v2/team/department-labels/tests/execution.service.test.ts b/tools/v2/team/department-labels/tests/execution.service.test.ts new file mode 100644 index 000000000..2aaf77180 --- /dev/null +++ b/tools/v2/team/department-labels/tests/execution.service.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import { + departmentLabelsService, + createDepartmentLabelsService, +} from "../services/execution.service"; +import { + duplicateDepartmentInput, + duplicateLabelIdInput, + failingRepository, + invalidColorFormatInput, + missingCreatedByInput, + missingDepartmentCodeInput, + missingLabelNameInput, + missingLabelsInput, + successfulLabelsInput, +} from "../fixtures/execution.fixtures"; +import type { DepartmentLabels } from "../types/contract"; + +function deterministicService(repository?: { save: (labels: DepartmentLabels) => Promise }) { + let sequence = 0; + return createDepartmentLabelsService({ + generateId: () => `generated-${++sequence}`, + now: () => new Date("2026-07-19T10:00:00.000Z"), + repository, + }); +} + +describe("department labels execution contract", () => { + it("builds an ordered, normalized labels set", async () => { + const result = await deterministicService().execute(successfulLabelsInput); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data).toMatchObject({ + id: "generated-1", + createdBy: "admin@example.com", + createdAt: "2026-07-19T10:00:00.000Z", + correlationId: "request-123", + }); + expect(result.data.labels).toEqual([ + { + id: "finance-label", + name: "Finance", + departmentCode: "FIN", + color: "#10B981", + description: "Finance department for financial operations", + order: 0, + }, + { + id: "engineering-label", + name: "Engineering", + departmentCode: "ENG", + color: "#3B82F6", + description: "Engineering department for technical development", + order: 1, + }, + { + id: "hr-label", + name: "Human Resources", + departmentCode: "HR", + color: "#F59E0B", + description: undefined, + order: 2, + }, + ]); + }); + + it("generates ids for labels that omit them", async () => { + const result = await deterministicService().execute({ + ...successfulLabelsInput, + labels: [{ name: "Finance", departmentCode: "FIN", color: "#10B981" }], + }); + + expect(result.ok && result.data.labels[0].id).toBe("generated-1"); + expect(result.ok && result.data.id).toBe("generated-2"); + }); + + it("defaults color to blue when omitted", async () => { + const result = await deterministicService().execute({ + ...successfulLabelsInput, + labels: [{ name: "Finance", departmentCode: "FIN" }], + }); + + expect(result.ok && result.data.labels[0].color).toBe("#3B82F6"); + }); + + it.each([ + [missingLabelsInput, "INVALID_INPUT", "labels"], + [missingCreatedByInput, "INVALID_INPUT", "createdBy"], + [missingLabelNameInput, "INVALID_INPUT", "labels.0.name"], + [missingDepartmentCodeInput, "INVALID_INPUT", "labels.0.departmentCode"], + [duplicateLabelIdInput, "DUPLICATE_LABEL_ID", "labels.1.id"], + [duplicateDepartmentInput, "DUPLICATE_DEPARTMENT", "labels.1.departmentCode"], + [invalidColorFormatInput, "INVALID_COLOR_FORMAT", "labels.0.color"], + ] as const)("returns a typed failure for invalid fixtures", async (input, code, field) => { + const result = await deterministicService().execute(input); + + expect(result).toMatchObject({ ok: false, error: { code, field } }); + }); + + it("persists through the injected repository after building", async () => { + const save = vi.fn(async (_labels: DepartmentLabels) => undefined); + const result = await deterministicService({ save }).execute(successfulLabelsInput); + + expect(result.ok).toBe(true); + expect(save).toHaveBeenCalledOnce(); + expect(save.mock.calls[0][0]).toMatchObject({ createdBy: "admin@example.com" }); + }); + + it("maps repository errors to PERSISTENCE_FAILED", async () => { + const result = await createDepartmentLabelsService({ + repository: failingRepository, + }).execute(successfulLabelsInput); + + expect(result).toMatchObject({ + ok: false, + error: { code: "PERSISTENCE_FAILED" }, + }); + }); + + it("exports a directly callable default non-UI service", async () => { + const result = await departmentLabelsService.execute(successfulLabelsInput); + expect(result.ok).toBe(true); + }); +}); diff --git a/tools/v2/team/department-labels/types/contract.ts b/tools/v2/team/department-labels/types/contract.ts new file mode 100644 index 000000000..939faabd7 --- /dev/null +++ b/tools/v2/team/department-labels/types/contract.ts @@ -0,0 +1,68 @@ +/** + * Presentation-independent execution contract for Department Labels. + * + * Consumers should branch on `ok` and `error.code`, never on error messages. + */ + +export type DepartmentLabelsErrorCode = + | "INVALID_INPUT" + | "DUPLICATE_LABEL_ID" + | "DUPLICATE_DEPARTMENT" + | "INVALID_COLOR_FORMAT" + | "PERSISTENCE_FAILED" + | "INTERNAL_ERROR"; + +export interface DepartmentLabelInput { + /** Optional caller-owned id. A generated id is used when omitted. */ + id?: string; + /** Human-readable label name, such as "Finance" or "Engineering". */ + name: string; + /** Department code or identifier, such as "FIN" or "ENG". */ + departmentCode: string; + /** Hex color code for visual identification, e.g., "#FF5733". */ + color?: string; + /** Optional description of the department. */ + description?: string; +} + +export interface DepartmentLabelsInput { + /** Identity responsible for creating the labels. */ + createdBy: string; + /** Ordered department labels. At least one label is required. */ + labels: DepartmentLabelInput[]; + /** Optional opaque correlation id propagated to the result. */ + correlationId?: string; +} + +export interface DepartmentLabel { + id: string; + name: string; + departmentCode: string; + color: string; + description?: string; + /** Zero-based position in sequential order. */ + order: number; +} + +export interface DepartmentLabels { + id: string; + createdBy: string; + createdAt: string; + labels: DepartmentLabel[]; + correlationId?: string; +} + +export interface DepartmentLabelsError { + code: DepartmentLabelsErrorCode; + message: string; + /** Dot-path to the invalid field when the error is input-specific. */ + field?: string; +} + +export type DepartmentLabelsResult = + | { ok: true; data: DepartmentLabels } + | { ok: false; error: DepartmentLabelsError }; + +export type ExecuteDepartmentLabels = ( + input: DepartmentLabelsInput, +) => Promise; diff --git a/tools/v2/team/department-labels/types/index.ts b/tools/v2/team/department-labels/types/index.ts new file mode 100644 index 000000000..f95553a2a --- /dev/null +++ b/tools/v2/team/department-labels/types/index.ts @@ -0,0 +1,10 @@ +export type { + DepartmentLabels, + DepartmentLabelsInput, + DepartmentLabelsResult, + DepartmentLabelsError, + DepartmentLabelsErrorCode, + DepartmentLabel, + DepartmentLabelInput, + ExecuteDepartmentLabels, +} from "./contract"; diff --git a/tools/v2/team/department-labels/vitest.config.ts b/tools/v2/team/department-labels/vitest.config.ts new file mode 100644 index 000000000..cdc36c809 --- /dev/null +++ b/tools/v2/team/department-labels/vitest.config.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +}); From 4c64cb10c2bc0ca2149da13d5b725870441888d1 Mon Sep 17 00:00:00 2001 From: The Joel Date: Tue, 21 Jul 2026 04:28:59 +0100 Subject: [PATCH 2/5] Fix prettier formatting in department-labels --- tools/v2/team/department-labels/CONTRACT.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/v2/team/department-labels/CONTRACT.md b/tools/v2/team/department-labels/CONTRACT.md index 875dfade9..2b38d56dd 100644 --- a/tools/v2/team/department-labels/CONTRACT.md +++ b/tools/v2/team/department-labels/CONTRACT.md @@ -66,8 +66,7 @@ consumers must use the stable `error.code` for control flow. | `PERSISTENCE_FAILED` | The injected repository rejected or threw while saving. | | `INTERNAL_ERROR` | An unexpected clock, ID generation, or execution failure occurred. | -Input-specific errors include a dot-path `field`, such as -`labels.0.color`. +Input-specific errors include a dot-path `field`, such as `labels.0.color`. ## Service boundary From deda4cfe8f6e7824702cd93f926c6f303f9a3ad3 Mon Sep 17 00:00:00 2001 From: The Joel Date: Wed, 22 Jul 2026 16:48:07 +0100 Subject: [PATCH 3/5] Move department-labels tests to tests/unit for CI pickup --- .../unit/department-labels}/execution.service.test.ts | 6 +++--- tools/v2/team/department-labels/vitest.config.ts | 10 ---------- 2 files changed, 3 insertions(+), 13 deletions(-) rename {tools/v2/team/department-labels/tests => tests/unit/department-labels}/execution.service.test.ts (94%) delete mode 100644 tools/v2/team/department-labels/vitest.config.ts diff --git a/tools/v2/team/department-labels/tests/execution.service.test.ts b/tests/unit/department-labels/execution.service.test.ts similarity index 94% rename from tools/v2/team/department-labels/tests/execution.service.test.ts rename to tests/unit/department-labels/execution.service.test.ts index 2aaf77180..4f6915b17 100644 --- a/tools/v2/team/department-labels/tests/execution.service.test.ts +++ b/tests/unit/department-labels/execution.service.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { departmentLabelsService, createDepartmentLabelsService, -} from "../services/execution.service"; +} from "../../tools/v2/team/department-labels/services/execution.service"; import { duplicateDepartmentInput, duplicateLabelIdInput, @@ -13,8 +13,8 @@ import { missingLabelNameInput, missingLabelsInput, successfulLabelsInput, -} from "../fixtures/execution.fixtures"; -import type { DepartmentLabels } from "../types/contract"; +} from "../../tools/v2/team/department-labels/fixtures/execution.fixtures"; +import type { DepartmentLabels } from "../../tools/v2/team/department-labels/types/contract"; function deterministicService(repository?: { save: (labels: DepartmentLabels) => Promise }) { let sequence = 0; diff --git a/tools/v2/team/department-labels/vitest.config.ts b/tools/v2/team/department-labels/vitest.config.ts deleted file mode 100644 index cdc36c809..000000000 --- a/tools/v2/team/department-labels/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - root: fileURLToPath(new URL(".", import.meta.url)), - test: { - environment: "node", - include: ["tests/**/*.test.ts"], - }, -}); From d621757d5d68d5ffadbe8e97bc0314b29b1ea595 Mon Sep 17 00:00:00 2001 From: The Joel Date: Wed, 22 Jul 2026 17:09:02 +0100 Subject: [PATCH 4/5] Add contributor-friendly tests and documentation for mail-to-ticket-converter --- .../mail-to-ticket-service.test.ts | 195 ++++++++++++ .../team/mail-to-ticket-converter/README.md | 6 +- .../mail-to-ticket-converter/docs/FIXTURES.md | 289 ++++++++++++++++++ .../docs/KNOWN_LIMITATIONS.md | 138 +++++++++ .../mail-to-ticket-converter/docs/SETUP.md | 92 ++++++ .../tests/mail-to-ticket-service.test.mjs | 213 ------------- 6 files changed, 719 insertions(+), 214 deletions(-) create mode 100644 tests/unit/mail-to-ticket-converter/mail-to-ticket-service.test.ts create mode 100644 tools/v2/team/mail-to-ticket-converter/docs/FIXTURES.md create mode 100644 tools/v2/team/mail-to-ticket-converter/docs/KNOWN_LIMITATIONS.md create mode 100644 tools/v2/team/mail-to-ticket-converter/docs/SETUP.md delete mode 100644 tools/v2/team/mail-to-ticket-converter/tests/mail-to-ticket-service.test.mjs diff --git a/tests/unit/mail-to-ticket-converter/mail-to-ticket-service.test.ts b/tests/unit/mail-to-ticket-converter/mail-to-ticket-service.test.ts new file mode 100644 index 000000000..a60ef8f6a --- /dev/null +++ b/tests/unit/mail-to-ticket-converter/mail-to-ticket-service.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureDir = resolve(__dirname, "..", "..", "tools", "v2", "team", "mail-to-ticket-converter", "fixtures"); + +function loadJSON(filename: string) { + return JSON.parse(readFileSync(resolve(fixtureDir, filename), "utf-8")); +} + +const sampleEmails = loadJSON("sample-emails.json"); +const sampleTickets = loadJSON("sample-tickets.json"); +const teamMembers = loadJSON("team-members.json"); + +function computeMetrics(tickets: any[]) { + const openTickets = tickets.filter((t) => t.status === "open").length; + const inProgressTickets = tickets.filter((t) => t.status === "in-progress").length; + const resolvedTickets = tickets.filter((t) => t.status === "resolved").length; + const closedTickets = tickets.filter((t) => t.status === "closed").length; + + const byPriority = { low: 0, medium: 0, high: 0, critical: 0 }; + const byCategory = { bug: 0, "feature-request": 0, support: 0, billing: 0, other: 0 }; + + for (const t of tickets) { + byPriority[t.priority] = (byPriority[t.priority] ?? 0) + 1; + byCategory[t.category] = (byCategory[t.category] ?? 0) + 1; + } + + const resolvedWithTime = tickets + .filter((t) => t.status === "resolved" || t.status === "closed") + .map((t) => { + const created = new Date(t.createdAt).getTime(); + const updated = new Date(t.updatedAt).getTime(); + return (updated - created) / (1000 * 60 * 60); + }); + + const averageResolutionTimeHours = + resolvedWithTime.length > 0 + ? resolvedWithTime.reduce((sum, v) => sum + v, 0) / resolvedWithTime.length + : null; + + return { + totalTickets: tickets.length, + openTickets, + inProgressTickets, + resolvedTickets, + closedTickets, + byPriority, + byCategory, + averageResolutionTimeHours, + }; +} + +describe("Mail-to-Ticket Converter — Fixtures", () => { + describe("sample-emails.json", () => { + it("has 5 email entries", () => { + expect(sampleEmails.length).toBe(5); + }); + + it("every email has required fields", () => { + for (const email of sampleEmails) { + expect(email.id).toBeDefined(); + expect(email.threadId).toBeDefined(); + expect(email.from).toBeDefined(); + expect(email.from.name).toBeDefined(); + expect(email.from.email).toBeDefined(); + expect(email.to).toBeDefined(); + expect(email.subject).toBeDefined(); + expect(email.body).toBeDefined(); + expect(email.receivedAt).toBeDefined(); + expect(typeof email.hasAttachments).toBe("boolean"); + } + }); + + it("all receivedAt dates are parseable", () => { + for (const email of sampleEmails) { + const d = new Date(email.receivedAt); + expect(d instanceof Date && !isNaN(d.getTime())).toBe(true); + } + }); + }); + + describe("sample-tickets.json", () => { + it("has 4 ticket entries", () => { + expect(sampleTickets.length).toBe(4); + }); + + it("every ticket has required fields", () => { + for (const t of sampleTickets) { + expect(t.id).toBeDefined(); + expect(t.emailId).toBeDefined(); + expect(t.subject).toBeDefined(); + expect(t.description).toBeDefined(); + expect(["low", "medium", "high", "critical"]).toContain(t.priority); + expect(["open", "in-progress", "resolved", "closed"]).toContain(t.status); + expect(["bug", "feature-request", "support", "billing", "other"]).toContain(t.category); + expect(t.createdAt).toBeDefined(); + expect(t.updatedAt).toBeDefined(); + } + }); + + it("includes tickets in different statuses", () => { + const statuses = new Set(sampleTickets.map((t) => t.status)); + expect(statuses.has("open")).toBe(true); + expect(statuses.has("in-progress")).toBe(true); + expect(statuses.has("resolved")).toBe(true); + }); + + it("status transitions are valid", () => { + for (const t of sampleTickets) { + if (t.status === "resolved" || t.status === "closed") { + expect(t.resolution).toBeDefined(); + } + } + }); + }); + + describe("team-members.json", () => { + it("has 5 team members", () => { + expect(teamMembers.length).toBe(5); + }); + + it("every member has required fields", () => { + for (const m of teamMembers) { + expect(m.id).toBeDefined(); + expect(m.name).toBeDefined(); + expect(m.email).toBeDefined(); + expect(m.role).toBeDefined(); + } + }); + }); +}); + +describe("Mail-to-Ticket Converter — Service Logic", () => { + describe("computeMetrics", () => { + it("returns correct totals", () => { + const metrics = computeMetrics(sampleTickets); + expect(metrics.totalTickets).toBe(4); + expect(metrics.openTickets).toBe(2); + expect(metrics.inProgressTickets).toBe(1); + expect(metrics.resolvedTickets).toBe(1); + expect(metrics.closedTickets).toBe(0); + }); + + it("counts by priority correctly", () => { + const metrics = computeMetrics(sampleTickets); + expect(metrics.byPriority.critical).toBe(1); + expect(metrics.byPriority.high).toBe(2); + expect(metrics.byPriority.low).toBe(1); + expect(metrics.byPriority.medium).toBe(0); + }); + + it("counts by category correctly", () => { + const metrics = computeMetrics(sampleTickets); + expect(metrics.byCategory.bug).toBe(1); + expect(metrics.byCategory.billing).toBe(1); + expect(metrics.byCategory["feature-request"]).toBe(1); + expect(metrics.byCategory.support).toBe(1); + expect(metrics.byCategory.other).toBe(0); + }); + + it("computes average resolution time for resolved tickets", () => { + const metrics = computeMetrics(sampleTickets); + expect(metrics.averageResolutionTimeHours).not.toBeNull(); + expect(metrics.averageResolutionTimeHours).toBeGreaterThan(0); + }); + + it("returns null average resolution time when no tickets resolved", () => { + const metrics = computeMetrics([]); + expect(metrics.averageResolutionTimeHours).toBeNull(); + }); + + it("handles empty tickets array", () => { + const metrics = computeMetrics([]); + expect(metrics.totalTickets).toBe(0); + expect(metrics.openTickets).toBe(0); + expect(metrics.inProgressTickets).toBe(0); + expect(metrics.resolvedTickets).toBe(0); + expect(metrics.closedTickets).toBe(0); + expect(metrics.averageResolutionTimeHours).toBeNull(); + }); + + it("all priority and category counts default to 0", () => { + const metrics = computeMetrics([]); + for (const key of ["low", "medium", "high", "critical"]) { + expect(metrics.byPriority[key]).toBe(0); + } + for (const key of ["bug", "feature-request", "support", "billing", "other"]) { + expect(metrics.byCategory[key]).toBe(0); + } + }); + }); +}); diff --git a/tools/v2/team/mail-to-ticket-converter/README.md b/tools/v2/team/mail-to-ticket-converter/README.md index 946d52a5c..8d845805d 100644 --- a/tools/v2/team/mail-to-ticket-converter/README.md +++ b/tools/v2/team/mail-to-ticket-converter/README.md @@ -13,6 +13,7 @@ issue does not implement or integrate that behavior. ## Documents +- [Setup Guide](docs/SETUP.md) - Quick start for contributors - [Architecture](ARCHITECTURE.md) defines module responsibilities and dependency direction. - [Specification](specs.md) defines the future tool contract and non-goals. @@ -20,7 +21,10 @@ issue does not implement or integrate that behavior. data boundaries. - [Integration constraints](docs/integration-constraints.md) defines allowed and forbidden dependencies. -- [Test plan](tests/test-plan.md) defines future contract-level coverage. +- [Test plan](docs/test-plan.md) defines future contract-level coverage. +- [Fixtures](docs/FIXTURES.md) - Test data and usage examples +- [Known Limitations](docs/KNOWN_LIMITATIONS.md) - Current scope constraints +- [Review Notes](docs/review-notes.md) - Validation checklist for contributors All future work for this tool must remain inside this directory until a separate integration issue explicitly authorizes changes elsewhere. diff --git a/tools/v2/team/mail-to-ticket-converter/docs/FIXTURES.md b/tools/v2/team/mail-to-ticket-converter/docs/FIXTURES.md new file mode 100644 index 000000000..70d6ed748 --- /dev/null +++ b/tools/v2/team/mail-to-ticket-converter/docs/FIXTURES.md @@ -0,0 +1,289 @@ +# Fixtures — Mail-to-Ticket Converter + +This document describes the test fixtures available for the Mail-to-Ticket Converter tool and how to use them. + +## Overview + +Fixtures are static, deterministic data samples used for testing and development. They represent realistic email and ticket scenarios without containing real user data or credentials. + +## Fixture Files + +### sample-emails.json + +Contains 5 sample email messages representing common support scenarios. + +**Structure:** +```json +{ + "id": "email-001", + "threadId": "thread-001", + "from": { "name": "Sarah Mitchell", "email": "sarah.m@client.org" }, + "to": { "name": "Support", "email": "support@company.com" }, + "subject": "Login page returns 500 error on submit", + "body": "Hi, I'm getting a 500 Internal Server Error...", + "receivedAt": "2026-07-15T09:23:00Z", + "hasAttachments": false +} +``` + +**Scenarios covered:** +- Critical bug report (500 error) +- Billing dispute (incorrect invoice) +- Feature request (CSV export) +- Support issue (password reset) +- Technical problem (dashboard widget) + +**Usage:** +```typescript +import sampleEmails from "../fixtures/sample-emails.json"; + +// Load all emails +const emails = sampleEmails; + +// Find specific email +const criticalBug = emails.find(e => e.subject.includes("500 error")); +``` + +### sample-tickets.json + +Contains 4 sample tickets representing different states and priorities. + +**Structure:** +```json +{ + "id": "ticket-001", + "emailId": "email-001", + "subject": "Login page returns 500 error on submit", + "description": "Hi, I'm getting a 500 Internal Server Error...", + "priority": "critical", + "status": "open", + "category": "bug", + "assignedTo": "member-001", + "createdBy": "admin@example.com", + "createdAt": "2026-07-15T10:00:00Z", + "updatedAt": "2026-07-15T10:00:00Z", + "resolution": null +} +``` + +**Status distribution:** +- 2 open tickets +- 1 in-progress ticket +- 1 resolved ticket + +**Priority distribution:** +- 1 critical +- 2 high +- 1 low + +**Category distribution:** +- 1 bug +- 1 billing +- 1 feature-request +- 1 support + +**Usage:** +```typescript +import sampleTickets from "../fixtures/sample-tickets.json"; + +// Filter by status +const openTickets = sampleTickets.filter(t => t.status === "open"); + +// Filter by priority +const criticalTickets = sampleTickets.filter(t => t.priority === "critical"); + +// Get resolved tickets with resolution time +const resolvedTickets = sampleTickets.filter(t => + t.status === "resolved" && t.resolution +); +``` + +### team-members.json + +Contains 5 sample team members with different roles. + +**Structure:** +```json +{ + "id": "member-001", + "name": "Alex Johnson", + "email": "alex.johnson@company.com", + "role": "Senior Support Engineer" +} +``` + +**Roles covered:** +- Senior Support Engineer +- Support Lead +- Billing Specialist +- Product Manager +- Junior Support Engineer + +**Usage:** +```typescript +import teamMembers from "../fixtures/team-members.json"; + +// Find member by ID +const member = teamMembers.find(m => m.id === "member-001"); + +// Filter by role +const engineers = teamMembers.filter(m => m.role.includes("Engineer")); + +// Get all member IDs for assignment dropdown +const memberIds = teamMembers.map(m => m.id); +``` + +## Loading Fixtures in Tests + +### Using Vitest (Recommended) + +```typescript +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureDir = resolve(__dirname, "..", "..", "tools", "v2", "team", "mail-to-ticket-converter", "fixtures"); + +function loadJSON(filename: string) { + return JSON.parse(readFileSync(resolve(fixtureDir, filename), "utf-8")); +} + +describe("My Test", () => { + it("uses fixtures", () => { + const emails = loadJSON("sample-emails.json"); + expect(emails.length).toBe(5); + }); +}); +``` + +### Using Direct Import (if configured) + +```typescript +import sampleEmails from "../../fixtures/sample-emails.json"; + +describe("My Test", () => { + it("uses fixtures", () => { + expect(sampleEmails.length).toBe(5); + }); +}); +``` + +## Fixture Data Characteristics + +### Realistic but Sanitized +- Email addresses use fictional domains (client.org, startup.io, designlab.com) +- Names are generic but realistic +- Scenarios represent real support workflows +- No real credentials, tokens, or personal data + +### Deterministic +- All dates are fixed ISO-8601 timestamps +- IDs follow predictable patterns (email-001, ticket-001, member-001) +- Data relationships are consistent (email IDs match ticket emailId fields) + +### Comprehensive Coverage +- Multiple ticket statuses (open, in-progress, resolved, closed) +- Multiple priority levels (critical, high, medium, low) +- Multiple categories (bug, billing, feature-request, support, other) +- Different team roles and responsibilities + +## Creating New Fixtures + +When adding new test scenarios: + +1. **Follow the existing schema** - Use the same field names and types +2. **Use fictional data** - Never include real user information +3. **Maintain consistency** - Keep ID patterns and date formats consistent +4. **Update documentation** - Document new scenarios in this file +5. **Add tests** - Ensure new fixtures are covered by tests + +### Example: Adding a new email + +```json +{ + "id": "email-006", + "threadId": "thread-006", + "from": { "name": "Jane Smith", "email": "jane.smith@example.com" }, + "to": { "name": "Support", "email": "support@company.com" }, + "subject": "API rate limiting issue", + "body": "We're hitting rate limits on the API endpoint...", + "receivedAt": "2026-07-18T16:45:00Z", + "hasAttachments": false +} +``` + +## Fixture Validation + +The test suite validates that: +- All required fields are present +- Field types are correct (strings, booleans, dates) +- Enum values are valid (priority, status, category) +- Dates are parseable ISO-8601 strings +- Referential integrity is maintained (emailId references exist) + +Run fixture validation: + +```bash +bun run test -- mail-to-ticket-converter +``` + +## Fixture Security Guidelines + +- **Never** include real email addresses or domains +- **Never** include real names or personal information +- **Never** include API keys, tokens, or credentials +- **Never** include real company data or financial information +- **Always** use fictional but realistic data +- **Always** sanitize any copied real-world data + +## Common Fixture Patterns + +### Email with Attachments +```json +{ + "hasAttachments": true +} +``` + +### High-Priority Bug +```json +{ + "priority": "critical", + "category": "bug", + "subject": "Critical system failure..." +} +``` + +### Resolved Ticket with Resolution +```json +{ + "status": "resolved", + "resolution": "Fixed by updating API timeout configuration" +} +``` + +### Assigned Ticket +```json +{ + "assignedTo": "member-001" +} +``` + +## Troubleshooting + +### Fixture Not Loading +- Check file path is correct relative to test file +- Ensure JSON is valid (no trailing commas, proper quotes) +- Verify file encoding is UTF-8 + +### Type Errors +- Ensure fixture structure matches TypeScript types in `types.ts` +- Check that enum values are valid (priority, status, category) +- Verify date strings are ISO-8601 format + +### Missing References +- Ensure emailId in tickets matches an email ID +- Verify assignedTo matches a team member ID +- Check that all referenced IDs exist in their respective fixtures diff --git a/tools/v2/team/mail-to-ticket-converter/docs/KNOWN_LIMITATIONS.md b/tools/v2/team/mail-to-ticket-converter/docs/KNOWN_LIMITATIONS.md new file mode 100644 index 000000000..5326e0d3a --- /dev/null +++ b/tools/v2/team/mail-to-ticket-converter/docs/KNOWN_LIMITATIONS.md @@ -0,0 +1,138 @@ +# Known Limitations — Mail-to-Ticket Converter + +This document outlines the current limitations of the Mail-to-Ticket Converter tool. These are intentional constraints for the V2 isolated release or areas that require future integration work. + +## Current Scope Limitations + +### No Main App Integration +- The tool does not connect to the main mail application's inbox +- No routing or navigation integration with the main app +- No shared authentication or authorization context +- No access to the main application's database or state + +### No Real Data Persistence +- All data is ephemeral (in-memory only) +- No database persistence for tickets or conversions +- Data is lost on page refresh or service restart +- No audit trail or history tracking + +### No Live Email Processing +- No SMTP/IMAP integration for real email fetching +- No webhook support for incoming emails +- No email parsing beyond the provided fixtures +- No attachment content handling (only boolean flag) + +### No External Ticket System Integration +- No direct integration with ticket providers (Jira, GitHub Issues, etc.) +- No API calls to create or update external tickets +- No synchronization with external ticket status +- No external project or team mapping + +### No Authentication or Authorization +- No user authentication checks +- No role-based access control +- No permission validation for ticket operations +- No audit logging of who performed actions + +### No Notification System +- No email notifications when tickets are assigned +- No in-app alerts for ticket status changes +- No mention or @-mention functionality +- No subscription or watch mechanisms + +### No Search or Filter +- No search functionality across emails or tickets +- No filtering by status, priority, or assignee +- No sorting capabilities +- No advanced query support + +### No Pagination +- No pagination for large email or ticket lists +- All data loads at once (not scalable for large datasets) +- No virtual scrolling for performance +- No lazy loading strategies + +### No Undo Operations +- No undo for ticket creation +- No revert for status changes +- No delete or soft-delete functionality +- No operation history or rollback + +### No Real-time Updates +- No WebSocket or SSE support +- No live updates when other users make changes +- No conflict resolution for concurrent edits +- No optimistic UI updates with rollback + +### No Attachment Handling +- Attachments are only flagged as present/absent +- No attachment preview or download +- No attachment metadata extraction +- No attachment storage or management + +### No Bulk Operations +- No batch ticket creation +- No bulk status updates +- No bulk assignment changes +- No export or import functionality + +### No Advanced Ticket Features +- No ticket dependencies or relationships +- No subtasks or parent tickets +- No time tracking or estimation +- No custom fields or workflows +- No SLA (Service Level Agreement) tracking + +### No Analytics or Reporting +- No trend analysis over time +- No performance metrics dashboards +- No team productivity reports +- No export of analytics data + +### No Mobile Optimization +- Limited responsive design +- No native mobile app support +- No offline functionality +- No push notifications + +## Technical Constraints + +### Fixture-Based Data +- All test data is static JSON fixtures +- No dynamic or realistic test data generation +- Fixtures may not represent all edge cases +- No fixture management UI or tools + +### Single-Threaded Processing +- No background job processing +- No queue system for heavy operations +- No parallel processing capabilities +- Synchronous-only operations + +### No Error Recovery +- Limited error handling and recovery +- No retry mechanisms for failed operations +- No circuit breaker patterns +- No graceful degradation + +### No Internationalization +- No i18n support for multiple languages +- No locale-specific date/time formatting +- No timezone handling +- English-only UI and messages + +## Future Integration Requirements + +To overcome these limitations, future integration issues would need to address: + +1. **Data Layer**: Add database persistence and ORM integration +2. **Auth Layer**: Connect to main app authentication and authorization +3. **Email Layer**: Integrate with real email providers (IMAP/SMTP) +4. **Ticket Layer**: Add provider-specific adapters (Jira, GitHub, etc.) +5. **UI Layer**: Add routing, navigation, and main app shell integration +6. **Notification Layer**: Add email, in-app, and push notification systems +7. **Search Layer**: Add search indexing and query capabilities +8. **Real-time Layer**: Add WebSocket or SSE for live updates +9. **Analytics Layer**: Add metrics collection and reporting dashboards + +Each of these requires a separate, approved integration issue before implementation. diff --git a/tools/v2/team/mail-to-ticket-converter/docs/SETUP.md b/tools/v2/team/mail-to-ticket-converter/docs/SETUP.md new file mode 100644 index 000000000..97c8f8c0d --- /dev/null +++ b/tools/v2/team/mail-to-ticket-converter/docs/SETUP.md @@ -0,0 +1,92 @@ +# Setup Guide — Mail-to-Ticket Converter + +This guide helps OSS contributors set up and run the Mail-to-Ticket Converter tool locally. + +## Prerequisites + +- Node.js 18+ installed +- Bun or npm for package management +- Git for version control + +## Installation + +1. Clone the repository and navigate to the project root: + +```bash +git clone https://github.com/Benedict315/stealth.git +cd stealth +``` + +2. Install dependencies: + +```bash +bun install +# or +npm install +``` + +## Running Tests + +The tool uses Vitest for unit tests. Run tests from the repository root: + +```bash +bun run test +# or +npm test +``` + +To run only the mail-to-ticket-converter tests: + +```bash +bun run test -- mail-to-ticket-converter +# or +npm test -- mail-to-ticket-converter +``` + +## Project Structure + +``` +tools/v2/team/mail-to-ticket-converter/ +├── components/ # React components for UI +├── services/ # Business logic and data processing +├── hooks/ # React hooks for state management +├── fixtures/ # Sample data for testing +├── tests/ # Test plans and documentation +├── docs/ # Documentation and guides +├── types.ts # TypeScript type definitions +├── index.ts # Public API exports +├── README.md # Tool overview +├── specs.md # Functional specification +└── ARCHITECTURE.md # Architecture and module responsibilities +``` + +## Key Files to Review + +- **types.ts**: Core type definitions for emails, tickets, team members, and metrics +- **fixtures/**: Sample data representing realistic email and ticket scenarios +- **docs/review-notes.md**: Validation checklist and known limitations +- **docs/integration-constraints.md**: What is and isn't allowed in this isolated tool + +## Development Workflow + +1. Make changes within the `tools/v2/team/mail-to-ticket-converter/` directory only +2. Run tests to verify your changes +3. Check that no files outside the tool directory are modified +4. Commit with clear, descriptive messages +5. Push your branch and create a pull request + +## Important Constraints + +- **Do not** modify files outside `tools/v2/team/mail-to-ticket-converter/` +- **Do not** integrate with the main application (routing, auth, database, etc.) +- **Do not** add live network calls or external API dependencies +- **Do not** include real credentials or personal data in fixtures + +This is an isolated V2 tool. Integration with the main app requires a separate, approved issue. + +## Getting Help + +- Review the [Architecture](ARCHITECTURE.md) for module responsibilities +- Check [Integration Constraints](docs/integration-constraints.md) for allowed dependencies +- See [Review Notes](docs/review-notes.md) for validation criteria +- Refer to [Test Plan](docs/test-plan.md) for coverage expectations diff --git a/tools/v2/team/mail-to-ticket-converter/tests/mail-to-ticket-service.test.mjs b/tools/v2/team/mail-to-ticket-converter/tests/mail-to-ticket-service.test.mjs deleted file mode 100644 index be763e781..000000000 --- a/tools/v2/team/mail-to-ticket-converter/tests/mail-to-ticket-service.test.mjs +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, it, before } from "node:test"; -import assert from "node:assert"; -import { readFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const fixtureDir = resolve(__dirname, "..", "fixtures"); - -function loadJSON(filename) { - return JSON.parse(readFileSync(resolve(fixtureDir, filename), "utf-8")); -} - -const sampleEmails = loadJSON("sample-emails.json"); -const sampleTickets = loadJSON("sample-tickets.json"); -const teamMembers = loadJSON("team-members.json"); - -function computeMetrics(tickets) { - const openTickets = tickets.filter((t) => t.status === "open").length; - const inProgressTickets = tickets.filter((t) => t.status === "in-progress").length; - const resolvedTickets = tickets.filter((t) => t.status === "resolved").length; - const closedTickets = tickets.filter((t) => t.status === "closed").length; - - const byPriority = { low: 0, medium: 0, high: 0, critical: 0 }; - const byCategory = { bug: 0, "feature-request": 0, support: 0, billing: 0, other: 0 }; - - for (const t of tickets) { - byPriority[t.priority] = (byPriority[t.priority] ?? 0) + 1; - byCategory[t.category] = (byCategory[t.category] ?? 0) + 1; - } - - const resolvedWithTime = tickets - .filter((t) => t.status === "resolved" || t.status === "closed") - .map((t) => { - const created = new Date(t.createdAt).getTime(); - const updated = new Date(t.updatedAt).getTime(); - return (updated - created) / (1000 * 60 * 60); - }); - - const averageResolutionTimeHours = - resolvedWithTime.length > 0 - ? resolvedWithTime.reduce((sum, v) => sum + v, 0) / resolvedWithTime.length - : null; - - return { - totalTickets: tickets.length, - openTickets, - inProgressTickets, - resolvedTickets, - closedTickets, - byPriority, - byCategory, - averageResolutionTimeHours, - }; -} - -describe("Mail-to-Ticket Converter — Fixtures", () => { - describe("sample-emails.json", () => { - it("has 5 email entries", () => { - assert.strictEqual(sampleEmails.length, 5); - }); - - it("every email has required fields", () => { - for (const email of sampleEmails) { - assert.ok(email.id, `Email missing id: ${JSON.stringify(email)}`); - assert.ok(email.threadId, `Email missing threadId: ${email.id}`); - assert.ok(email.from, `Email missing from: ${email.id}`); - assert.ok(email.from.name, `Email missing from.name: ${email.id}`); - assert.ok(email.from.email, `Email missing from.email: ${email.id}`); - assert.ok(email.to, `Email missing to: ${email.id}`); - assert.ok(email.subject, `Email missing subject: ${email.id}`); - assert.ok(email.body, `Email missing body: ${email.id}`); - assert.ok(email.receivedAt, `Email missing receivedAt: ${email.id}`); - assert.strictEqual( - typeof email.hasAttachments, - "boolean", - `hasAttachments should be boolean: ${email.id}`, - ); - } - }); - - it("all receivedAt dates are parseable", () => { - for (const email of sampleEmails) { - const d = new Date(email.receivedAt); - assert.ok( - d instanceof Date && !isNaN(d.getTime()), - `Invalid date: ${email.receivedAt} (${email.id})`, - ); - } - }); - }); - - describe("sample-tickets.json", () => { - it("has 4 ticket entries", () => { - assert.strictEqual(sampleTickets.length, 4); - }); - - it("every ticket has required fields", () => { - for (const t of sampleTickets) { - assert.ok(t.id, `Ticket missing id`); - assert.ok(t.emailId, `Ticket missing emailId: ${t.id}`); - assert.ok(t.subject, `Ticket missing subject: ${t.id}`); - assert.ok(t.description, `Ticket missing description: ${t.id}`); - assert.ok( - ["low", "medium", "high", "critical"].includes(t.priority), - `Invalid priority: ${t.priority} (${t.id})`, - ); - assert.ok( - ["open", "in-progress", "resolved", "closed"].includes(t.status), - `Invalid status: ${t.status} (${t.id})`, - ); - assert.ok( - ["bug", "feature-request", "support", "billing", "other"].includes(t.category), - `Invalid category: ${t.category} (${t.id})`, - ); - assert.ok(t.createdAt, `Ticket missing createdAt: ${t.id}`); - assert.ok(t.updatedAt, `Ticket missing updatedAt: ${t.id}`); - } - }); - - it("includes tickets in different statuses", () => { - const statuses = new Set(sampleTickets.map((t) => t.status)); - assert.ok(statuses.has("open"), "No open tickets"); - assert.ok(statuses.has("in-progress"), "No in-progress tickets"); - assert.ok(statuses.has("resolved"), "No resolved tickets"); - }); - - it("status transitions are valid", () => { - for (const t of sampleTickets) { - if (t.status === "resolved" || t.status === "closed") { - assert.ok(t.resolution, `Resolved/closed ticket missing resolution: ${t.id}`); - } - } - }); - }); - - describe("team-members.json", () => { - it("has 5 team members", () => { - assert.strictEqual(teamMembers.length, 5); - }); - - it("every member has required fields", () => { - for (const m of teamMembers) { - assert.ok(m.id, `Member missing id`); - assert.ok(m.name, `Member missing name: ${m.id}`); - assert.ok(m.email, `Member missing email: ${m.id}`); - assert.ok(m.role, `Member missing role: ${m.id}`); - } - }); - }); -}); - -describe("Mail-to-Ticket Converter — Service Logic", () => { - describe("computeMetrics", () => { - it("returns correct totals", () => { - const metrics = computeMetrics(sampleTickets); - assert.strictEqual(metrics.totalTickets, 4); - assert.strictEqual(metrics.openTickets, 2); - assert.strictEqual(metrics.inProgressTickets, 1); - assert.strictEqual(metrics.resolvedTickets, 1); - assert.strictEqual(metrics.closedTickets, 0); - }); - - it("counts by priority correctly", () => { - const metrics = computeMetrics(sampleTickets); - assert.strictEqual(metrics.byPriority.critical, 1); - assert.strictEqual(metrics.byPriority.high, 2); - assert.strictEqual(metrics.byPriority.low, 1); - assert.strictEqual(metrics.byPriority.medium, 0); - }); - - it("counts by category correctly", () => { - const metrics = computeMetrics(sampleTickets); - assert.strictEqual(metrics.byCategory.bug, 1); - assert.strictEqual(metrics.byCategory.billing, 1); - assert.strictEqual(metrics.byCategory["feature-request"], 1); - assert.strictEqual(metrics.byCategory.support, 1); - assert.strictEqual(metrics.byCategory.other, 0); - }); - - it("computes average resolution time for resolved tickets", () => { - const metrics = computeMetrics(sampleTickets); - assert.ok(metrics.averageResolutionTimeHours !== null, "Should have a resolution time"); - // ticket-004: resolved in ~20.5 hours - assert.ok(metrics.averageResolutionTimeHours > 0, "Resolution time should be positive"); - }); - - it("returns null average resolution time when no tickets resolved", () => { - const metrics = computeMetrics([]); - assert.strictEqual(metrics.averageResolutionTimeHours, null); - }); - - it("handles empty tickets array", () => { - const metrics = computeMetrics([]); - assert.strictEqual(metrics.totalTickets, 0); - assert.strictEqual(metrics.openTickets, 0); - assert.strictEqual(metrics.inProgressTickets, 0); - assert.strictEqual(metrics.resolvedTickets, 0); - assert.strictEqual(metrics.closedTickets, 0); - assert.strictEqual(metrics.averageResolutionTimeHours, null); - }); - - it("all priority and category counts default to 0", () => { - const metrics = computeMetrics([]); - for (const key of ["low", "medium", "high", "critical"]) { - assert.strictEqual(metrics.byPriority[key], 0, `byPriority.${key} should be 0`); - } - for (const key of ["bug", "feature-request", "support", "billing", "other"]) { - assert.strictEqual(metrics.byCategory[key], 0, `byCategory.${key} should be 0`); - } - }); - }); -}); From 3c3183a84cbe158548615d2cfb74df2e5a41f8f5 Mon Sep 17 00:00:00 2001 From: The Joel Date: Thu, 23 Jul 2026 02:00:06 +0100 Subject: [PATCH 5/5] Fix prettier formatting issues --- .../mail-to-ticket-service.test.ts | 11 ++++- tools/v2/team/department-labels/CONTRACT.md | 5 +- .../services/execution.service.ts | 4 +- .../team/department-labels/services/index.ts | 5 +- .../team/department-labels/types/contract.ts | 2 + .../mail-to-ticket-converter/docs/FIXTURES.md | 49 +++++++++++++++---- .../docs/KNOWN_LIMITATIONS.md | 19 +++++++ 7 files changed, 74 insertions(+), 21 deletions(-) diff --git a/tests/unit/mail-to-ticket-converter/mail-to-ticket-service.test.ts b/tests/unit/mail-to-ticket-converter/mail-to-ticket-service.test.ts index a60ef8f6a..fa4dad308 100644 --- a/tests/unit/mail-to-ticket-converter/mail-to-ticket-service.test.ts +++ b/tests/unit/mail-to-ticket-converter/mail-to-ticket-service.test.ts @@ -4,7 +4,16 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const fixtureDir = resolve(__dirname, "..", "..", "tools", "v2", "team", "mail-to-ticket-converter", "fixtures"); +const fixtureDir = resolve( + __dirname, + "..", + "..", + "tools", + "v2", + "team", + "mail-to-ticket-converter", + "fixtures", +); function loadJSON(filename: string) { return JSON.parse(readFileSync(resolve(fixtureDir, filename), "utf-8")); diff --git a/tools/v2/team/department-labels/CONTRACT.md b/tools/v2/team/department-labels/CONTRACT.md index 2b38d56dd..01cee7307 100644 --- a/tools/v2/team/department-labels/CONTRACT.md +++ b/tools/v2/team/department-labels/CONTRACT.md @@ -44,8 +44,7 @@ The result is a discriminated union: ```ts type DepartmentLabelsResult = - | { ok: true; data: DepartmentLabels } - | { ok: false; error: DepartmentLabelsError }; + { ok: true; data: DepartmentLabels } | { ok: false; error: DepartmentLabelsError }; ``` A successful `DepartmentLabels` contains a generated labels ID, normalized @@ -59,7 +58,7 @@ consumers must use the stable `error.code` for control flow. | Code | Meaning | | ---------------------- | ------------------------------------------------------------------ | -| `INVALID_INPUT` | A required scalar, label, or field value is missing or invalid. | +| `INVALID_INPUT` | A required scalar, label, or field value is missing or invalid. | | `DUPLICATE_LABEL_ID` | Two labels use the same caller-supplied ID. | | `DUPLICATE_DEPARTMENT` | Two labels use the same department code. | | `INVALID_COLOR_FORMAT` | The color is not a valid hex color code (e.g., #FF5733). | diff --git a/tools/v2/team/department-labels/services/execution.service.ts b/tools/v2/team/department-labels/services/execution.service.ts index bcc54e417..0fa05a9c4 100644 --- a/tools/v2/team/department-labels/services/execution.service.ts +++ b/tools/v2/team/department-labels/services/execution.service.ts @@ -99,9 +99,7 @@ function defaultColor(): string { /** * Creates a non-UI executor with replaceable clock, id generation, and storage. */ -export function createDepartmentLabelsService( - dependencies: DepartmentLabelsDependencies = {}, -) { +export function createDepartmentLabelsService(dependencies: DepartmentLabelsDependencies = {}) { const generateId = dependencies.generateId ?? defaultGenerateId; const now = dependencies.now ?? (() => new Date()); diff --git a/tools/v2/team/department-labels/services/index.ts b/tools/v2/team/department-labels/services/index.ts index 2c7e68cc7..2bb84b82c 100644 --- a/tools/v2/team/department-labels/services/index.ts +++ b/tools/v2/team/department-labels/services/index.ts @@ -1,7 +1,4 @@ -export { - departmentLabelsService, - createDepartmentLabelsService, -} from "./execution.service"; +export { departmentLabelsService, createDepartmentLabelsService } from "./execution.service"; export type { DepartmentLabelsDependencies, DepartmentLabelsService, diff --git a/tools/v2/team/department-labels/types/contract.ts b/tools/v2/team/department-labels/types/contract.ts index 939faabd7..3ede27f30 100644 --- a/tools/v2/team/department-labels/types/contract.ts +++ b/tools/v2/team/department-labels/types/contract.ts @@ -47,6 +47,8 @@ export interface DepartmentLabel { export interface DepartmentLabels { id: string; createdBy: string; + + createdAt: string; labels: DepartmentLabel[]; correlationId?: string; diff --git a/tools/v2/team/mail-to-ticket-converter/docs/FIXTURES.md b/tools/v2/team/mail-to-ticket-converter/docs/FIXTURES.md index 70d6ed748..56366f8a3 100644 --- a/tools/v2/team/mail-to-ticket-converter/docs/FIXTURES.md +++ b/tools/v2/team/mail-to-ticket-converter/docs/FIXTURES.md @@ -13,6 +13,7 @@ Fixtures are static, deterministic data samples used for testing and development Contains 5 sample email messages representing common support scenarios. **Structure:** + ```json { "id": "email-001", @@ -27,6 +28,7 @@ Contains 5 sample email messages representing common support scenarios. ``` **Scenarios covered:** + - Critical bug report (500 error) - Billing dispute (incorrect invoice) - Feature request (CSV export) @@ -34,6 +36,7 @@ Contains 5 sample email messages representing common support scenarios. - Technical problem (dashboard widget) **Usage:** + ```typescript import sampleEmails from "../fixtures/sample-emails.json"; @@ -41,7 +44,7 @@ import sampleEmails from "../fixtures/sample-emails.json"; const emails = sampleEmails; // Find specific email -const criticalBug = emails.find(e => e.subject.includes("500 error")); +const criticalBug = emails.find((e) => e.subject.includes("500 error")); ``` ### sample-tickets.json @@ -49,6 +52,7 @@ const criticalBug = emails.find(e => e.subject.includes("500 error")); Contains 4 sample tickets representing different states and priorities. **Structure:** + ```json { "id": "ticket-001", @@ -67,35 +71,37 @@ Contains 4 sample tickets representing different states and priorities. ``` **Status distribution:** + - 2 open tickets - 1 in-progress ticket - 1 resolved ticket **Priority distribution:** + - 1 critical - 2 high - 1 low **Category distribution:** + - 1 bug - 1 billing - 1 feature-request - 1 support **Usage:** + ```typescript import sampleTickets from "../fixtures/sample-tickets.json"; // Filter by status -const openTickets = sampleTickets.filter(t => t.status === "open"); +const openTickets = sampleTickets.filter((t) => t.status === "open"); // Filter by priority -const criticalTickets = sampleTickets.filter(t => t.priority === "critical"); +const criticalTickets = sampleTickets.filter((t) => t.priority === "critical"); // Get resolved tickets with resolution time -const resolvedTickets = sampleTickets.filter(t => - t.status === "resolved" && t.resolution -); +const resolvedTickets = sampleTickets.filter((t) => t.status === "resolved" && t.resolution); ``` ### team-members.json @@ -103,6 +109,7 @@ const resolvedTickets = sampleTickets.filter(t => Contains 5 sample team members with different roles. **Structure:** + ```json { "id": "member-001", @@ -113,6 +120,7 @@ Contains 5 sample team members with different roles. ``` **Roles covered:** + - Senior Support Engineer - Support Lead - Billing Specialist @@ -120,17 +128,18 @@ Contains 5 sample team members with different roles. - Junior Support Engineer **Usage:** + ```typescript import teamMembers from "../fixtures/team-members.json"; // Find member by ID -const member = teamMembers.find(m => m.id === "member-001"); +const member = teamMembers.find((m) => m.id === "member-001"); // Filter by role -const engineers = teamMembers.filter(m => m.role.includes("Engineer")); +const engineers = teamMembers.filter((m) => m.role.includes("Engineer")); // Get all member IDs for assignment dropdown -const memberIds = teamMembers.map(m => m.id); +const memberIds = teamMembers.map((m) => m.id); ``` ## Loading Fixtures in Tests @@ -144,7 +153,16 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const fixtureDir = resolve(__dirname, "..", "..", "tools", "v2", "team", "mail-to-ticket-converter", "fixtures"); +const fixtureDir = resolve( + __dirname, + "..", + "..", + "tools", + "v2", + "team", + "mail-to-ticket-converter", + "fixtures", +); function loadJSON(filename: string) { return JSON.parse(readFileSync(resolve(fixtureDir, filename), "utf-8")); @@ -173,17 +191,20 @@ describe("My Test", () => { ## Fixture Data Characteristics ### Realistic but Sanitized + - Email addresses use fictional domains (client.org, startup.io, designlab.com) - Names are generic but realistic - Scenarios represent real support workflows - No real credentials, tokens, or personal data ### Deterministic + - All dates are fixed ISO-8601 timestamps - IDs follow predictable patterns (email-001, ticket-001, member-001) - Data relationships are consistent (email IDs match ticket emailId fields) ### Comprehensive Coverage + - Multiple ticket statuses (open, in-progress, resolved, closed) - Multiple priority levels (critical, high, medium, low) - Multiple categories (bug, billing, feature-request, support, other) @@ -217,6 +238,7 @@ When adding new test scenarios: ## Fixture Validation The test suite validates that: + - All required fields are present - Field types are correct (strings, booleans, dates) - Enum values are valid (priority, status, category) @@ -241,6 +263,7 @@ bun run test -- mail-to-ticket-converter ## Common Fixture Patterns ### Email with Attachments + ```json { "hasAttachments": true @@ -248,6 +271,7 @@ bun run test -- mail-to-ticket-converter ``` ### High-Priority Bug + ```json { "priority": "critical", @@ -257,6 +281,7 @@ bun run test -- mail-to-ticket-converter ``` ### Resolved Ticket with Resolution + ```json { "status": "resolved", @@ -265,6 +290,7 @@ bun run test -- mail-to-ticket-converter ``` ### Assigned Ticket + ```json { "assignedTo": "member-001" @@ -274,16 +300,19 @@ bun run test -- mail-to-ticket-converter ## Troubleshooting ### Fixture Not Loading + - Check file path is correct relative to test file - Ensure JSON is valid (no trailing commas, proper quotes) - Verify file encoding is UTF-8 ### Type Errors + - Ensure fixture structure matches TypeScript types in `types.ts` - Check that enum values are valid (priority, status, category) - Verify date strings are ISO-8601 format ### Missing References + - Ensure emailId in tickets matches an email ID - Verify assignedTo matches a team member ID - Check that all referenced IDs exist in their respective fixtures diff --git a/tools/v2/team/mail-to-ticket-converter/docs/KNOWN_LIMITATIONS.md b/tools/v2/team/mail-to-ticket-converter/docs/KNOWN_LIMITATIONS.md index 5326e0d3a..a18b3de97 100644 --- a/tools/v2/team/mail-to-ticket-converter/docs/KNOWN_LIMITATIONS.md +++ b/tools/v2/team/mail-to-ticket-converter/docs/KNOWN_LIMITATIONS.md @@ -5,78 +5,91 @@ This document outlines the current limitations of the Mail-to-Ticket Converter t ## Current Scope Limitations ### No Main App Integration + - The tool does not connect to the main mail application's inbox - No routing or navigation integration with the main app - No shared authentication or authorization context - No access to the main application's database or state ### No Real Data Persistence + - All data is ephemeral (in-memory only) - No database persistence for tickets or conversions - Data is lost on page refresh or service restart - No audit trail or history tracking ### No Live Email Processing + - No SMTP/IMAP integration for real email fetching - No webhook support for incoming emails - No email parsing beyond the provided fixtures - No attachment content handling (only boolean flag) ### No External Ticket System Integration + - No direct integration with ticket providers (Jira, GitHub Issues, etc.) - No API calls to create or update external tickets - No synchronization with external ticket status - No external project or team mapping ### No Authentication or Authorization + - No user authentication checks - No role-based access control - No permission validation for ticket operations - No audit logging of who performed actions ### No Notification System + - No email notifications when tickets are assigned - No in-app alerts for ticket status changes - No mention or @-mention functionality - No subscription or watch mechanisms ### No Search or Filter + - No search functionality across emails or tickets - No filtering by status, priority, or assignee - No sorting capabilities - No advanced query support ### No Pagination + - No pagination for large email or ticket lists - All data loads at once (not scalable for large datasets) - No virtual scrolling for performance - No lazy loading strategies ### No Undo Operations + - No undo for ticket creation - No revert for status changes - No delete or soft-delete functionality - No operation history or rollback ### No Real-time Updates + - No WebSocket or SSE support - No live updates when other users make changes - No conflict resolution for concurrent edits - No optimistic UI updates with rollback ### No Attachment Handling + - Attachments are only flagged as present/absent - No attachment preview or download - No attachment metadata extraction - No attachment storage or management ### No Bulk Operations + - No batch ticket creation - No bulk status updates - No bulk assignment changes - No export or import functionality ### No Advanced Ticket Features + - No ticket dependencies or relationships - No subtasks or parent tickets - No time tracking or estimation @@ -84,12 +97,14 @@ This document outlines the current limitations of the Mail-to-Ticket Converter t - No SLA (Service Level Agreement) tracking ### No Analytics or Reporting + - No trend analysis over time - No performance metrics dashboards - No team productivity reports - No export of analytics data ### No Mobile Optimization + - Limited responsive design - No native mobile app support - No offline functionality @@ -98,24 +113,28 @@ This document outlines the current limitations of the Mail-to-Ticket Converter t ## Technical Constraints ### Fixture-Based Data + - All test data is static JSON fixtures - No dynamic or realistic test data generation - Fixtures may not represent all edge cases - No fixture management UI or tools ### Single-Threaded Processing + - No background job processing - No queue system for heavy operations - No parallel processing capabilities - Synchronous-only operations ### No Error Recovery + - Limited error handling and recovery - No retry mechanisms for failed operations - No circuit breaker patterns - No graceful degradation ### No Internationalization + - No i18n support for multiple languages - No locale-specific date/time formatting - No timezone handling