Files
xboct-flow/src/storage/FsTaskRepository.test.ts
T

269 lines
8.1 KiB
TypeScript

import { Task } from "@/model/task";
import { ITaskRepository } from "@/ports";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { TaskPriority } from "@/model/taskPriority";
import { TaskStatus } from "@/model/taskStatus";
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { parseTaskFile } from "@/utils/markdown";
import { FsTaskRepository } from "./FsTaskRepository";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
describe("FsTaskRepository", () => {
let fs: InMemoryFileSystem;
let repo: ITaskRepository;
let clockStep: number;
const folder = "/tasks";
const t0 = "2026-01-01T00:00:00.000Z";
const t1 = "2026-01-01T00:00:01.000Z";
const t2 = "2026-01-01T00:00:02.000Z";
const sampleTask = {
title: "Fix login bug",
description: "Token validation error",
status: TaskStatus.OPEN,
priority: TaskPriority.HIGH,
tags: ["bug", "frontend"],
assignee: "ivanov",
};
beforeEach(() => {
fs = new InMemoryFileSystem();
clockStep = 0;
const timestamps = [t0, t1, t2];
repo = new FsTaskRepository(fs, TASK_FILE_EXTENSION, () => {
const value = timestamps[clockStep] ?? t2;
clockStep += 1;
return value;
});
});
it("creates a task and writes it as a task file", async () => {
const task = await repo.create(folder, sampleTask);
expect(task.id).toMatch(UUID_RE);
expect(task.title).toBe("Fix login bug");
expect(task.created).toBe(t0);
expect(task.updated).toBe(t0);
const files = await fs.readdir(folder);
expect(files).toEqual([`fix-login-bug${TASK_FILE_EXTENSION}`]);
const content = await fs.readFile(
`${folder}/fix-login-bug${TASK_FILE_EXTENSION}`,
);
expect(content).toContain("Fix login bug");
expect(content).toContain("status: open");
});
it("list returns created tasks sorted by updated desc", async () => {
await repo.create(folder, { ...sampleTask, title: "A" });
await repo.create(folder, { ...sampleTask, title: "B" });
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(2);
expect(tasks[0].title).toBe("B");
expect(tasks[0].updated).toBe(t1);
expect(tasks[1].title).toBe("A");
expect(tasks[1].updated).toBe(t0);
});
it("getById returns the correct task", async () => {
const created = await repo.create(folder, sampleTask);
const found = await repo.getById(folder, created.id);
expect(found).toEqual(created);
});
it("getById returns undefined for unknown id", async () => {
const found = await repo.getById(folder, "nonexistent");
expect(found).toBeUndefined();
});
it("update modifies fields and bumps updated timestamp", async () => {
const task = await repo.create(folder, sampleTask);
const updated: Task = { ...task, status: TaskStatus.DONE };
await repo.update(folder, updated);
const found = await repo.getById(folder, task.id);
expect(found!.status).toBe(TaskStatus.DONE);
expect(found!.title).toBe(task.title);
expect(found!.updated).toBe(t1);
expect(found!.created).toBe(t0);
});
it("update renames file when title changes", async () => {
const task = await repo.create(folder, sampleTask);
expect(await fs.readdir(folder)).toEqual([
`fix-login-bug${TASK_FILE_EXTENSION}`,
]);
await repo.update(folder, { ...task, title: "Fixed!" });
const files = await fs.readdir(folder);
expect(files).toEqual([`fixed${TASK_FILE_EXTENSION}`]);
const found = await repo.getById(folder, task.id);
expect(found!.title).toBe("Fixed!");
expect(found!.updated).toBe(t1);
});
it("update of unknown id writes a new file", async () => {
const orphan: Task = {
...sampleTask,
id: "orphan-id",
title: "Orphan",
created: t0,
updated: t0,
};
await repo.update(folder, orphan);
const found = await repo.getById(folder, "orphan-id");
expect(found).toMatchObject({
id: "orphan-id",
title: "Orphan",
updated: t0,
});
expect(await fs.readdir(folder)).toEqual([`orphan${TASK_FILE_EXTENSION}`]);
});
it("delete removes the task file", async () => {
const task = await repo.create(folder, sampleTask);
await repo.delete(folder, task.id);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(0);
expect(await fs.readdir(folder)).toEqual([]);
});
it("delete is a no-op for unknown id", async () => {
await expect(repo.delete(folder, "nope")).resolves.toBeUndefined();
});
it("list returns empty array for non-existent folder", async () => {
const tasks = await repo.list("/nonexistent");
expect(tasks).toEqual([]);
});
it("filters files by extension", async () => {
await fs.writeFile(`${folder}/notes.txt`, "hello");
await fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n");
await repo.create(folder, sampleTask);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(1);
});
it("list returns empty when only non-matching extensions exist", async () => {
await fs.writeFile(`${folder}/notes.txt`, "hello");
await fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n");
const tasks = await repo.list(folder);
expect(tasks).toEqual([]);
});
it("skips files without id", async () => {
await fs.writeFile(
`${folder}/no-id${TASK_FILE_EXTENSION}`,
"not valid frontmatter",
);
await fs.writeFile(
`${folder}/empty-id${TASK_FILE_EXTENSION}`,
"---\ntitle: No Id\n---\n",
);
await repo.create(folder, sampleTask);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe("Fix login bug");
});
it("create with same title overwrites the previous file", async () => {
const first = await repo.create(folder, sampleTask);
const second = await repo.create(folder, {
...sampleTask,
description: "second",
});
const files = await fs.readdir(folder);
expect(files).toEqual([`fix-login-bug${TASK_FILE_EXTENSION}`]);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(1);
expect(tasks[0].id).toBe(second.id);
expect(tasks[0].id).not.toBe(first.id);
expect(tasks[0].description).toBe("second");
});
it("creates task with Cyrillic title and slug", async () => {
const task = await repo.create(folder, {
...sampleTask,
title: "Тестовая задача",
});
expect(task.title).toBe("Тестовая задача");
const files = await fs.readdir(folder);
expect(files).toEqual([`тестовая-задача${TASK_FILE_EXTENSION}`]);
});
it("creates task with title exactly at MAX_TASK_TITLE_LENGTH", async () => {
const title = "a".repeat(80);
const task = await repo.create(folder, { ...sampleTask, title });
expect(task.title).toBe(title);
const files = await fs.readdir(folder);
expect(files).toEqual([`${title}${TASK_FILE_EXTENSION}`]);
const found = await repo.getById(folder, task.id);
expect(found?.title).toBe(title);
});
it("creates task with tags containing special characters", async () => {
await repo.create(folder, {
...sampleTask,
tags: ["c#", "c++", "tag name", "tag:value"],
});
const files = await fs.readdir(folder);
expect(files).toHaveLength(1);
const content = await fs.readFile(
`${folder}/fix-login-bug${TASK_FILE_EXTENSION}`,
);
expect(content).toContain("c#");
expect(content).toContain("tag:value");
});
it("getFilePath produces consistent slug regardless of special characters", async () => {
await repo.create(folder, {
...sampleTask,
title: "Fix___bug...",
});
const files = await fs.readdir(folder);
expect(files).toEqual([`fix-bug${TASK_FILE_EXTENSION}`]);
});
it("integration: create → file → parse results in matching task", async () => {
const task = await repo.create(folder, sampleTask);
const content = await fs.readFile(
`${folder}/fix-login-bug${TASK_FILE_EXTENSION}`,
);
const parsed = parseTaskFile(content);
expect(parsed.id).toBe(task.id);
expect(parsed.title).toBe(sampleTask.title);
expect(parsed.description).toBe(sampleTask.description);
expect(parsed.status).toBe(sampleTask.status);
expect(parsed.priority).toBe(sampleTask.priority);
expect(parsed.tags).toEqual(sampleTask.tags);
expect(parsed.assignee).toBe(sampleTask.assignee);
expect(parsed.created).toBe(task.created);
expect(parsed.updated).toBe(task.updated);
});
});