feat: tasks workflow

This commit is contained in:
2026-07-13 16:00:25 +05:00
parent 9705bac7af
commit ce69209820
15 changed files with 559 additions and 31 deletions
+76
View File
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { AppError } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { changeStatus } from "./changeStatus";
describe("changeStatus", () => {
const folder = "/tasks";
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
});
it("updates status of an existing task", async () => {
const created = await repo.create(folder, {
title: "Work item",
description: "",
status: "todo",
priority: "medium",
tags: [],
assignee: "",
});
const result = await changeStatus(repo, {
folderPath: folder,
id: created.id,
status: "in-progress",
});
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value.status).toBe("in-progress");
expect(result.value.id).toBe(created.id);
const stored = await repo.getById(folder, created.id);
expect(stored?.status).toBe("in-progress");
});
it("returns not_found for unknown id", async () => {
const result = await changeStatus(repo, {
folderPath: folder,
id: "missing",
status: "done",
});
expect(result).toEqual(err(AppError.notFound()));
});
it("rejects missing folder", async () => {
const result = await changeStatus(repo, {
folderPath: undefined,
id: "x",
status: "done",
});
expect(result).toEqual(err(AppError.noFolder()));
});
it("rejects missing id", async () => {
const result = await changeStatus(repo, {
folderPath: folder,
id: undefined,
status: "done",
});
expect(result).toEqual(err(AppError.noId()));
});
it("rejects missing status", async () => {
const result = await changeStatus(repo, {
folderPath: folder,
id: "x",
status: undefined,
});
expect(result).toEqual(err(AppError.noStatus()));
});
});
+46
View File
@@ -0,0 +1,46 @@
import { errAsync, okAsync, ResultAsync } from "neverthrow";
import { AppError } from "@/error";
import { Task, TaskStatus } from "@/model/task";
import { ITaskRepository } from "@/ports";
export type ChangeStatusInput = {
folderPath?: string;
id?: string;
status?: TaskStatus;
};
export function changeStatus(
repo: ITaskRepository,
input: ChangeStatusInput,
): ResultAsync<Task, AppError> {
if (input.folderPath === undefined) {
return errAsync(AppError.noFolder());
}
if (input.id === undefined) {
return errAsync(AppError.noId());
}
if (input.status === undefined) {
return errAsync(AppError.noStatus());
}
const folderPath = input.folderPath;
const id = input.id;
const status = input.status;
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(existing) => {
if (!existing) {
return errAsync(AppError.notFound());
}
const next: Task = { ...existing, status };
return ResultAsync.fromSafePromise(repo.update(folderPath, next)).andThen(
() =>
ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(updated) =>
updated ? okAsync(updated) : errAsync(AppError.notFound()),
),
);
},
);
}
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { AppError } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { createTask } from "./createTask";
describe("createTask", () => {
const folder = "/tasks";
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
});
it("creates a todo/medium task from title", async () => {
const result = await createTask(repo, {
folderPath: folder,
title: " New feature ",
});
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value.title).toBe("New feature");
expect(result.value.status).toBe("todo");
expect(result.value.priority).toBe("medium");
expect(result.value.description).toBe("");
expect(result.value.tags).toEqual([]);
expect(result.value.assignee).toBe("");
const listed = await repo.list(folder);
expect(listed).toHaveLength(1);
expect(listed[0].id).toBe(result.value.id);
});
it("rejects empty title", async () => {
const result = await createTask(repo, {
folderPath: folder,
title: " ",
});
expect(result).toEqual(err(AppError.emptyTitle()));
expect(await repo.list(folder)).toEqual([]);
});
it("rejects missing title", async () => {
const result = await createTask(repo, {
folderPath: folder,
title: undefined,
});
expect(result).toEqual(err(AppError.emptyTitle()));
});
it("rejects missing folder", async () => {
const result = await createTask(repo, {
folderPath: undefined,
title: "Task",
});
expect(result).toEqual(err(AppError.noFolder()));
});
});
+34
View File
@@ -0,0 +1,34 @@
import { errAsync, ResultAsync } from "neverthrow";
import { AppError } from "@/error";
import { Task } from "@/model/task";
import { ITaskRepository } from "@/ports";
export type CreateTaskInput = {
folderPath?: string;
title?: string;
};
export function createTask(
repo: ITaskRepository,
input: CreateTaskInput,
): ResultAsync<Task, AppError> {
if (input.folderPath === undefined) {
return errAsync(AppError.noFolder());
}
const title = input.title?.trim() ?? "";
if (!title) {
return errAsync(AppError.emptyTitle());
}
return ResultAsync.fromSafePromise(
repo.create(input.folderPath, {
title,
description: "",
status: "todo",
priority: "medium",
tags: [],
assignee: "",
}),
);
}
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err, ok } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { AppError } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { deleteTask } from "./deleteTask";
describe("deleteTask", () => {
const folder = "/tasks";
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
});
it("deletes an existing task", async () => {
const created = await repo.create(folder, {
title: "To delete",
description: "",
status: "todo",
priority: "medium",
tags: [],
assignee: "",
});
const result = await deleteTask(repo, {
folderPath: folder,
id: created.id,
});
expect(result).toEqual(ok(undefined));
expect(await repo.list(folder)).toEqual([]);
});
it("returns not_found for unknown id", async () => {
const result = await deleteTask(repo, {
folderPath: folder,
id: "missing",
});
expect(result).toEqual(err(AppError.notFound()));
});
it("rejects missing folder", async () => {
const result = await deleteTask(repo, {
folderPath: undefined,
id: "x",
});
expect(result).toEqual(err(AppError.noFolder()));
});
it("rejects missing id", async () => {
const result = await deleteTask(repo, {
folderPath: folder,
id: undefined,
});
expect(result).toEqual(err(AppError.noId()));
});
});
+32
View File
@@ -0,0 +1,32 @@
import { errAsync, ResultAsync } from "neverthrow";
import { AppError } from "@/error";
import { ITaskRepository } from "@/ports";
export type DeleteTaskInput = {
folderPath?: string;
id?: string;
};
export function deleteTask(
repo: ITaskRepository,
input: DeleteTaskInput,
): ResultAsync<void, AppError> {
if (input.folderPath === undefined) {
return errAsync(AppError.noFolder());
}
if (input.id === undefined) {
return errAsync(AppError.noId());
}
const folderPath = input.folderPath;
const id = input.id;
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(existing) => {
if (!existing) {
return errAsync(AppError.notFound());
}
return ResultAsync.fromSafePromise(repo.delete(folderPath, id));
},
);
}
+60
View File
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { AppError } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { openTask } from "./openTask";
describe("openTask", () => {
const folder = "/tasks";
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
});
it("returns file path and task for an existing id", async () => {
const created = await repo.create(folder, {
title: "Open me",
description: "body",
status: "todo",
priority: "high",
tags: [],
assignee: "",
});
const result = await openTask(repo, {
folderPath: folder,
id: created.id,
});
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value.path).toBe("/tasks/open-me.task.md");
expect(result.value.task).toEqual(created);
});
it("returns not_found for unknown id", async () => {
const result = await openTask(repo, {
folderPath: folder,
id: "missing",
});
expect(result).toEqual(err(AppError.notFound()));
});
it("rejects missing folder", async () => {
const result = await openTask(repo, {
folderPath: undefined,
id: "x",
});
expect(result).toEqual(err(AppError.noFolder()));
});
it("rejects missing id", async () => {
const result = await openTask(repo, {
folderPath: folder,
id: undefined,
});
expect(result).toEqual(err(AppError.noId()));
});
});
+41
View File
@@ -0,0 +1,41 @@
import { errAsync, okAsync, ResultAsync } from "neverthrow";
import { AppError } from "@/error";
import { Task } from "@/model/task";
import { ITaskRepository } from "@/ports";
export type OpenTaskInput = {
folderPath?: string;
id?: string;
};
export type OpenTaskValue = {
path: string;
task: Task;
};
export function openTask(
repo: ITaskRepository,
input: OpenTaskInput,
): ResultAsync<OpenTaskValue, AppError> {
if (input.folderPath === undefined) {
return errAsync(AppError.noFolder());
}
if (input.id === undefined) {
return errAsync(AppError.noId());
}
const folderPath = input.folderPath;
const id = input.id;
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(task) => {
if (!task) {
return errAsync(AppError.notFound());
}
return okAsync({
path: repo.getFilePath(folderPath, task),
task,
});
},
);
}