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
This commit is contained in:
2026-01-29 22:42:59 +00:00
parent 96441b818e
commit 268ee5d0b2
5 changed files with 384 additions and 35 deletions

32
.gitea/workflows/ci.yml Normal file
View File

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

View File

@@ -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",

View File

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

101
backend/src/lib/utils.ts Normal file
View File

@@ -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-<number>".
* 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<string, number> = {
active: 0,
queued: 1,
blocked: 2,
completed: 3,
cancelled: 4,
};
return order[status] ?? 5;
}

View File

@@ -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<string, string | u
// Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5")
async function resolveTask(idOrNumber: string) {
// Strip "HQ-" prefix if present
const cleaned = idOrNumber.replace(/^HQ-/i, "");
const asNumber = parseInt(cleaned, 10);
const parsed = parseTaskIdentifier(idOrNumber);
let result;
if (!isNaN(asNumber) && String(asNumber) === cleaned) {
// Lookup by task_number
result = await db.select().from(tasks).where(eq(tasks.taskNumber, asNumber));
if (parsed.type === "number") {
result = await db.select().from(tasks).where(eq(tasks.taskNumber, parsed.value));
} else {
// Lookup by UUID
result = await db.select().from(tasks).where(eq(tasks.id, cleaned));
result = await db.select().from(tasks).where(eq(tasks.id, parsed.value));
}
return result[0] || null;
}