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:
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal 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
|
||||
@@ -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",
|
||||
|
||||
242
backend/src/lib/utils.test.ts
Normal file
242
backend/src/lib/utils.test.ts
Normal 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
101
backend/src/lib/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user