- 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
243 lines
8.7 KiB
TypeScript
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")
|
|
);
|
|
});
|
|
});
|