feat: tasks workflow
This commit is contained in:
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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()),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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: "",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** All application-level error codes. */
|
||||
export type AppErrorCode =
|
||||
| "no_folder"
|
||||
| "no_id"
|
||||
| "no_status"
|
||||
| "empty_title"
|
||||
| "not_found";
|
||||
|
||||
/** Discriminated app error returned via neverthrow `Result` / `ResultAsync`. */
|
||||
export type AppError = {
|
||||
readonly code: AppErrorCode;
|
||||
};
|
||||
|
||||
export const AppError = {
|
||||
noFolder: (): AppError => ({ code: "no_folder" }),
|
||||
noId: (): AppError => ({ code: "no_id" }),
|
||||
noStatus: (): AppError => ({ code: "no_status" }),
|
||||
emptyTitle: (): AppError => ({ code: "empty_title" }),
|
||||
notFound: (): AppError => ({ code: "not_found" }),
|
||||
} as const;
|
||||
@@ -17,6 +17,8 @@ export interface ITaskRepository {
|
||||
): Promise<Task>;
|
||||
update(folderPath: string, task: Task): Promise<void>;
|
||||
delete(folderPath: string, id: string): Promise<void>;
|
||||
/** Absolute/joined path for the task file in `folderPath`. */
|
||||
getFilePath(folderPath: string, task: Task): string;
|
||||
}
|
||||
|
||||
export interface IConfigProvider {
|
||||
|
||||
@@ -68,7 +68,7 @@ export class FsTaskRepository implements ITaskRepository {
|
||||
};
|
||||
|
||||
await this.#fs.mkdir(folderPath);
|
||||
const filePath = this.taskFilePath(folderPath, newTask);
|
||||
const filePath = this.getFilePath(folderPath, newTask);
|
||||
await this.#fs.writeFile(filePath, serializeTask(newTask));
|
||||
|
||||
return newTask;
|
||||
@@ -77,10 +77,10 @@ export class FsTaskRepository implements ITaskRepository {
|
||||
async update(folderPath: string, task: Task): Promise<void> {
|
||||
const existing = await this.getById(folderPath, task.id);
|
||||
const updatedTask = { ...task, updated: this.#now() };
|
||||
const newPath = this.taskFilePath(folderPath, updatedTask);
|
||||
const newPath = this.getFilePath(folderPath, updatedTask);
|
||||
|
||||
if (existing) {
|
||||
const oldPath = this.taskFilePath(folderPath, existing);
|
||||
const oldPath = this.getFilePath(folderPath, existing);
|
||||
if (oldPath !== newPath) {
|
||||
await this.#fs.deleteFile(oldPath).catch(() => {});
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export class FsTaskRepository implements ITaskRepository {
|
||||
const task = await this.getById(folderPath, id);
|
||||
if (!task) return;
|
||||
|
||||
const filePath = this.taskFilePath(folderPath, task);
|
||||
const filePath = this.getFilePath(folderPath, task);
|
||||
try {
|
||||
await this.#fs.deleteFile(filePath);
|
||||
} catch {
|
||||
@@ -101,7 +101,7 @@ export class FsTaskRepository implements ITaskRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private taskFilePath(folderPath: string, task: Task): string {
|
||||
getFilePath(folderPath: string, task: Task): string {
|
||||
const safeName = task.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
|
||||
|
||||
Reference in New Issue
Block a user