Files
hammer-queue/backend/src/lib/utils.test.ts
Hammer 268ee5d0b2 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
2026-01-29 22:42:59 +00:00

243 lines
8.7 KiB
TypeScript

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