From 268ee5d0b225141a65bb325e09a3e9f11ca93a31 Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 22:42:59 +0000 Subject: [PATCH] feat: add unit tests and Gitea Actions CI pipeline - Extract pure utility functions to lib/utils.ts for testability - Add 28 unit tests covering: computeNextDueDate, resetSubtasks, parseTaskIdentifier, validators, statusSortOrder - Add Gitea Actions workflow (.gitea/workflows/ci.yml) that runs tests and type checking on push/PR to main - Refactor tasks.ts to use extracted utils --- .gitea/workflows/ci.yml | 32 +++++ backend/package.json | 4 +- backend/src/lib/utils.test.ts | 242 ++++++++++++++++++++++++++++++++++ backend/src/lib/utils.ts | 101 ++++++++++++++ backend/src/routes/tasks.ts | 40 +----- 5 files changed, 384 insertions(+), 35 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 backend/src/lib/utils.test.ts create mode 100644 backend/src/lib/utils.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..fd35160 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests + run: bun test --bail + + - name: Type check + run: bun x tsc --noEmit diff --git a/backend/package.json b/backend/package.json index f120361..51dd066 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,9 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "test": "bun test", + "test:ci": "bun test --bail" }, "dependencies": { "@elysiajs/cors": "^1.2.0", diff --git a/backend/src/lib/utils.test.ts b/backend/src/lib/utils.test.ts new file mode 100644 index 0000000..3e01d16 --- /dev/null +++ b/backend/src/lib/utils.test.ts @@ -0,0 +1,242 @@ +import { describe, test, expect, beforeAll } from "bun:test"; +import { + computeNextDueDate, + resetSubtasks, + parseTaskIdentifier, + isValidTaskStatus, + isValidTaskPriority, + isValidTaskSource, + isValidRecurrenceFrequency, + statusSortOrder, +} from "./utils"; +import type { Subtask } from "../db/schema"; + +// ── computeNextDueDate ────────────────────────────────────────────── + +describe("computeNextDueDate", () => { + test("daily adds 1 day from now when no fromDate", () => { + const before = new Date(); + const result = computeNextDueDate("daily"); + const after = new Date(); + // Should be roughly 1 day from now + const diffMs = result.getTime() - before.getTime(); + const oneDayMs = 24 * 60 * 60 * 1000; + expect(diffMs).toBeGreaterThanOrEqual(oneDayMs - 1000); + expect(diffMs).toBeLessThanOrEqual(oneDayMs + 1000); + }); + + test("weekly adds 7 days", () => { + const before = new Date(); + const result = computeNextDueDate("weekly"); + const diffMs = result.getTime() - before.getTime(); + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + expect(diffMs).toBeGreaterThanOrEqual(sevenDaysMs - 1000); + expect(diffMs).toBeLessThanOrEqual(sevenDaysMs + 1000); + }); + + test("biweekly adds 14 days", () => { + const before = new Date(); + const result = computeNextDueDate("biweekly"); + const diffMs = result.getTime() - before.getTime(); + const fourteenDaysMs = 14 * 24 * 60 * 60 * 1000; + expect(diffMs).toBeGreaterThanOrEqual(fourteenDaysMs - 1000); + expect(diffMs).toBeLessThanOrEqual(fourteenDaysMs + 1000); + }); + + test("monthly adds approximately 1 month", () => { + const before = new Date(); + const result = computeNextDueDate("monthly"); + // Should be roughly 28-31 days from now + const diffDays = (result.getTime() - before.getTime()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBeGreaterThanOrEqual(27); + expect(diffDays).toBeLessThanOrEqual(32); + }); + + test("uses fromDate when it is in the future", () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 10); // 10 days from now + const result = computeNextDueDate("daily", futureDate); + // Should be futureDate + 1 day + const expected = new Date(futureDate); + expected.setDate(expected.getDate() + 1); + expect(result.getDate()).toBe(expected.getDate()); + }); + + test("ignores fromDate when it is in the past", () => { + const pastDate = new Date("2020-01-01"); + const before = new Date(); + const result = computeNextDueDate("daily", pastDate); + // Should be ~1 day from now, not from 2020 + expect(result.getFullYear()).toBeGreaterThanOrEqual(before.getFullYear()); + }); + + test("handles null fromDate", () => { + const before = new Date(); + const result = computeNextDueDate("weekly", null); + const diffMs = result.getTime() - before.getTime(); + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + expect(diffMs).toBeGreaterThanOrEqual(sevenDaysMs - 1000); + }); +}); + +// ── resetSubtasks ─────────────────────────────────────────────────── + +describe("resetSubtasks", () => { + test("resets all subtasks to uncompleted", () => { + const subtasks: Subtask[] = [ + { id: "st-1", title: "Do thing", completed: true, completedAt: "2025-01-01T00:00:00Z", createdAt: "2024-12-01T00:00:00Z" }, + { id: "st-2", title: "Do other thing", completed: false, createdAt: "2024-12-01T00:00:00Z" }, + { id: "st-3", title: "Done thing", completed: true, completedAt: "2025-01-15T00:00:00Z", createdAt: "2024-12-15T00:00:00Z" }, + ]; + const result = resetSubtasks(subtasks); + expect(result).toHaveLength(3); + for (const s of result) { + expect(s.completed).toBe(false); + expect(s.completedAt).toBeUndefined(); + } + }); + + test("preserves other fields", () => { + const subtasks: Subtask[] = [ + { id: "st-1", title: "My task", completed: true, completedAt: "2025-01-01T00:00:00Z", createdAt: "2024-12-01T00:00:00Z" }, + ]; + const result = resetSubtasks(subtasks); + expect(result[0].id).toBe("st-1"); + expect(result[0].title).toBe("My task"); + expect(result[0].createdAt).toBe("2024-12-01T00:00:00Z"); + }); + + test("handles empty array", () => { + expect(resetSubtasks([])).toEqual([]); + }); +}); + +// ── parseTaskIdentifier ───────────────────────────────────────────── + +describe("parseTaskIdentifier", () => { + test("parses plain number", () => { + const result = parseTaskIdentifier("42"); + expect(result).toEqual({ type: "number", value: 42 }); + }); + + test("parses HQ- prefixed number", () => { + const result = parseTaskIdentifier("HQ-7"); + expect(result).toEqual({ type: "number", value: 7 }); + }); + + test("parses hq- prefixed number (case insensitive)", () => { + const result = parseTaskIdentifier("hq-15"); + expect(result).toEqual({ type: "number", value: 15 }); + }); + + test("parses UUID", () => { + const uuid = "550e8400-e29b-41d4-a716-446655440000"; + const result = parseTaskIdentifier(uuid); + expect(result).toEqual({ type: "uuid", value: uuid }); + }); + + test("treats non-numeric strings as UUID", () => { + const result = parseTaskIdentifier("abc123"); + expect(result).toEqual({ type: "uuid", value: "abc123" }); + }); + + test("treats mixed number-string as UUID", () => { + const result = parseTaskIdentifier("12abc"); + expect(result).toEqual({ type: "uuid", value: "12abc" }); + }); +}); + +// ── Validators ────────────────────────────────────────────────────── + +describe("isValidTaskStatus", () => { + test("accepts valid statuses", () => { + expect(isValidTaskStatus("active")).toBe(true); + expect(isValidTaskStatus("queued")).toBe(true); + expect(isValidTaskStatus("blocked")).toBe(true); + expect(isValidTaskStatus("completed")).toBe(true); + expect(isValidTaskStatus("cancelled")).toBe(true); + }); + + test("rejects invalid statuses", () => { + expect(isValidTaskStatus("done")).toBe(false); + expect(isValidTaskStatus("")).toBe(false); + expect(isValidTaskStatus("ACTIVE")).toBe(false); + }); +}); + +describe("isValidTaskPriority", () => { + test("accepts valid priorities", () => { + expect(isValidTaskPriority("critical")).toBe(true); + expect(isValidTaskPriority("high")).toBe(true); + expect(isValidTaskPriority("medium")).toBe(true); + expect(isValidTaskPriority("low")).toBe(true); + }); + + test("rejects invalid priorities", () => { + expect(isValidTaskPriority("urgent")).toBe(false); + expect(isValidTaskPriority("")).toBe(false); + }); +}); + +describe("isValidTaskSource", () => { + test("accepts valid sources", () => { + expect(isValidTaskSource("donovan")).toBe(true); + expect(isValidTaskSource("hammer")).toBe(true); + expect(isValidTaskSource("heartbeat")).toBe(true); + expect(isValidTaskSource("cron")).toBe(true); + expect(isValidTaskSource("other")).toBe(true); + expect(isValidTaskSource("david")).toBe(true); + }); + + test("rejects invalid sources", () => { + expect(isValidTaskSource("system")).toBe(false); + expect(isValidTaskSource("")).toBe(false); + }); +}); + +describe("isValidRecurrenceFrequency", () => { + test("accepts valid frequencies", () => { + expect(isValidRecurrenceFrequency("daily")).toBe(true); + expect(isValidRecurrenceFrequency("weekly")).toBe(true); + expect(isValidRecurrenceFrequency("biweekly")).toBe(true); + expect(isValidRecurrenceFrequency("monthly")).toBe(true); + }); + + test("rejects invalid frequencies", () => { + expect(isValidRecurrenceFrequency("yearly")).toBe(false); + expect(isValidRecurrenceFrequency("")).toBe(false); + }); +}); + +// ── statusSortOrder ───────────────────────────────────────────────── + +describe("statusSortOrder", () => { + test("active sorts first", () => { + expect(statusSortOrder("active")).toBe(0); + }); + + test("cancelled sorts last among known statuses", () => { + expect(statusSortOrder("cancelled")).toBe(4); + }); + + test("maintains correct ordering", () => { + const statuses = ["cancelled", "active", "blocked", "queued", "completed"]; + const sorted = [...statuses].sort( + (a, b) => statusSortOrder(a) - statusSortOrder(b) + ); + expect(sorted).toEqual([ + "active", + "queued", + "blocked", + "completed", + "cancelled", + ]); + }); + + test("unknown status gets highest sort value", () => { + expect(statusSortOrder("unknown")).toBe(5); + expect(statusSortOrder("unknown")).toBeGreaterThan( + statusSortOrder("cancelled") + ); + }); +}); diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts new file mode 100644 index 0000000..28d0ddd --- /dev/null +++ b/backend/src/lib/utils.ts @@ -0,0 +1,101 @@ +/** + * Pure utility functions extracted for testability. + * No database or external dependencies. + */ + +import type { RecurrenceFrequency, Subtask } from "../db/schema"; + +/** + * Compute the next due date for a recurring task based on frequency. + */ +export function computeNextDueDate( + frequency: RecurrenceFrequency, + fromDate?: Date | null +): Date { + const base = + fromDate && fromDate > new Date() ? new Date(fromDate) : new Date(); + switch (frequency) { + case "daily": + base.setDate(base.getDate() + 1); + break; + case "weekly": + base.setDate(base.getDate() + 7); + break; + case "biweekly": + base.setDate(base.getDate() + 14); + break; + case "monthly": + base.setMonth(base.getMonth() + 1); + break; + } + return base; +} + +/** + * Reset subtasks for a new recurrence instance (uncheck all). + */ +export function resetSubtasks(subtasks: Subtask[]): Subtask[] { + return subtasks.map((s) => ({ + ...s, + completed: false, + completedAt: undefined, + })); +} + +/** + * Parse a task identifier - could be a UUID, a number, or "HQ-". + * Returns { type: "number", value: number } or { type: "uuid", value: string }. + */ +export function parseTaskIdentifier(idOrNumber: string): + | { type: "number"; value: number } + | { type: "uuid"; value: string } { + const cleaned = idOrNumber.replace(/^HQ-/i, ""); + const asNumber = parseInt(cleaned, 10); + if (!isNaN(asNumber) && String(asNumber) === cleaned) { + return { type: "number", value: asNumber }; + } + return { type: "uuid", value: cleaned }; +} + +/** + * Validate that a status string is a valid task status. + */ +export function isValidTaskStatus(status: string): boolean { + return ["active", "queued", "blocked", "completed", "cancelled"].includes(status); +} + +/** + * Validate that a priority string is a valid task priority. + */ +export function isValidTaskPriority(priority: string): boolean { + return ["critical", "high", "medium", "low"].includes(priority); +} + +/** + * Validate that a source string is a valid task source. + */ +export function isValidTaskSource(source: string): boolean { + return ["donovan", "david", "hammer", "heartbeat", "cron", "other"].includes(source); +} + +/** + * Validate a recurrence frequency string. + */ +export function isValidRecurrenceFrequency(freq: string): boolean { + return ["daily", "weekly", "biweekly", "monthly"].includes(freq); +} + +/** + * Sort status values by priority order (active first). + * Returns a numeric sort key. + */ +export function statusSortOrder(status: string): number { + const order: Record = { + active: 0, + queued: 1, + blocked: 2, + completed: 3, + cancelled: 4, + }; + return order[status] ?? 5; +} diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index eb3876d..059eb7f 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -3,6 +3,7 @@ import { db } from "../db"; import { tasks, type ProgressNote, type Subtask, type Recurrence, type RecurrenceFrequency } from "../db/schema"; import { eq, asc, desc, sql, inArray, or } from "drizzle-orm"; import { auth } from "../lib/auth"; +import { computeNextDueDate, resetSubtasks, parseTaskIdentifier } from "../lib/utils"; const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; const CLAWDBOT_HOOK_URL = process.env.CLAWDBOT_HOOK_URL || "https://hammer.donovankelly.xyz/hooks/agent"; @@ -41,26 +42,6 @@ async function notifyTaskActivated(task: { id: string; title: string; descriptio } } -// Compute the next due date for a recurring task -function computeNextDueDate(frequency: RecurrenceFrequency, fromDate?: Date | null): Date { - const base = fromDate && fromDate > new Date() ? new Date(fromDate) : new Date(); - switch (frequency) { - case "daily": - base.setDate(base.getDate() + 1); - break; - case "weekly": - base.setDate(base.getDate() + 7); - break; - case "biweekly": - base.setDate(base.getDate() + 14); - break; - case "monthly": - base.setMonth(base.getMonth() + 1); - break; - } - return base; -} - // Create the next instance of a recurring task async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) { const recurrence = completedTask.recurrence as Recurrence | null; @@ -96,11 +77,7 @@ async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) { estimatedHours: completedTask.estimatedHours, tags: completedTask.tags, recurrence: recurrence, - subtasks: (completedTask.subtasks as Subtask[] || []).map(s => ({ - ...s, - completed: false, - completedAt: undefined, - })), + subtasks: resetSubtasks(completedTask.subtasks as Subtask[] || []), progressNotes: [], }) .returning(); @@ -161,17 +138,12 @@ async function requireAdmin(request: Request, headers: Record