refactor: updater errors

This commit is contained in:
2026-07-13 16:42:26 +05:00
parent ce69209820
commit 2f09b3754e
11 changed files with 139 additions and 58 deletions
+8 -6
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { AppError } from "@/error";
import { appError, AppErrorVariant } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { changeStatus } from "./changeStatus";
@@ -38,13 +38,15 @@ describe("changeStatus", () => {
expect(stored?.status).toBe("in-progress");
});
it("returns not_found for unknown id", async () => {
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()));
expect(result).toEqual(
err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })),
);
});
it("rejects missing folder", async () => {
@@ -53,7 +55,7 @@ describe("changeStatus", () => {
id: "x",
status: "done",
});
expect(result).toEqual(err(AppError.noFolder()));
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
});
it("rejects missing id", async () => {
@@ -62,7 +64,7 @@ describe("changeStatus", () => {
id: undefined,
status: "done",
});
expect(result).toEqual(err(AppError.noId()));
expect(result).toEqual(err(appError(AppErrorVariant.NO_ID)));
});
it("rejects missing status", async () => {
@@ -71,6 +73,6 @@ describe("changeStatus", () => {
id: "x",
status: undefined,
});
expect(result).toEqual(err(AppError.noStatus()));
expect(result).toEqual(err(appError(AppErrorVariant.NO_STATUS)));
});
});
+8 -6
View File
@@ -1,5 +1,5 @@
import { errAsync, okAsync, ResultAsync } from "neverthrow";
import { AppError } from "@/error";
import { appError, AppError, AppErrorVariant } from "@/error";
import { Task, TaskStatus } from "@/model/task";
import { ITaskRepository } from "@/ports";
@@ -14,13 +14,13 @@ export function changeStatus(
input: ChangeStatusInput,
): ResultAsync<Task, AppError> {
if (input.folderPath === undefined) {
return errAsync(AppError.noFolder());
return errAsync(appError(AppErrorVariant.NO_FOLDER));
}
if (input.id === undefined) {
return errAsync(AppError.noId());
return errAsync(appError(AppErrorVariant.NO_ID));
}
if (input.status === undefined) {
return errAsync(AppError.noStatus());
return errAsync(appError(AppErrorVariant.NO_STATUS));
}
const folderPath = input.folderPath;
@@ -30,7 +30,7 @@ export function changeStatus(
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(existing) => {
if (!existing) {
return errAsync(AppError.notFound());
return errAsync(appError(AppErrorVariant.NOT_FOUND, { id }));
}
const next: Task = { ...existing, status };
@@ -38,7 +38,9 @@ export function changeStatus(
() =>
ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(updated) =>
updated ? okAsync(updated) : errAsync(AppError.notFound()),
updated
? okAsync(updated)
: errAsync(appError(AppErrorVariant.NOT_FOUND, { id })),
),
);
},
+4 -4
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { AppError } from "@/error";
import { appError, AppErrorVariant } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { createTask } from "./createTask";
@@ -39,7 +39,7 @@ describe("createTask", () => {
folderPath: folder,
title: " ",
});
expect(result).toEqual(err(AppError.emptyTitle()));
expect(result).toEqual(err(appError(AppErrorVariant.EMPTY_TITLE)));
expect(await repo.list(folder)).toEqual([]);
});
@@ -48,7 +48,7 @@ describe("createTask", () => {
folderPath: folder,
title: undefined,
});
expect(result).toEqual(err(AppError.emptyTitle()));
expect(result).toEqual(err(appError(AppErrorVariant.EMPTY_TITLE)));
});
it("rejects missing folder", async () => {
@@ -56,6 +56,6 @@ describe("createTask", () => {
folderPath: undefined,
title: "Task",
});
expect(result).toEqual(err(AppError.noFolder()));
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
});
});
+3 -3
View File
@@ -1,5 +1,5 @@
import { errAsync, ResultAsync } from "neverthrow";
import { AppError } from "@/error";
import { appError, AppError, AppErrorVariant } from "@/error";
import { Task } from "@/model/task";
import { ITaskRepository } from "@/ports";
@@ -13,12 +13,12 @@ export function createTask(
input: CreateTaskInput,
): ResultAsync<Task, AppError> {
if (input.folderPath === undefined) {
return errAsync(AppError.noFolder());
return errAsync(appError(AppErrorVariant.NO_FOLDER));
}
const title = input.title?.trim() ?? "";
if (!title) {
return errAsync(AppError.emptyTitle());
return errAsync(appError(AppErrorVariant.EMPTY_TITLE));
}
return ResultAsync.fromSafePromise(
+7 -5
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err, ok } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { AppError } from "@/error";
import { appError, AppErrorVariant } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { deleteTask } from "./deleteTask";
@@ -32,12 +32,14 @@ describe("deleteTask", () => {
expect(await repo.list(folder)).toEqual([]);
});
it("returns not_found for unknown id", async () => {
it("returns not-found for unknown id", async () => {
const result = await deleteTask(repo, {
folderPath: folder,
id: "missing",
});
expect(result).toEqual(err(AppError.notFound()));
expect(result).toEqual(
err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })),
);
});
it("rejects missing folder", async () => {
@@ -45,7 +47,7 @@ describe("deleteTask", () => {
folderPath: undefined,
id: "x",
});
expect(result).toEqual(err(AppError.noFolder()));
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
});
it("rejects missing id", async () => {
@@ -53,6 +55,6 @@ describe("deleteTask", () => {
folderPath: folder,
id: undefined,
});
expect(result).toEqual(err(AppError.noId()));
expect(result).toEqual(err(appError(AppErrorVariant.NO_ID)));
});
});
+4 -4
View File
@@ -1,5 +1,5 @@
import { errAsync, ResultAsync } from "neverthrow";
import { AppError } from "@/error";
import { appError, AppError, AppErrorVariant } from "@/error";
import { ITaskRepository } from "@/ports";
export type DeleteTaskInput = {
@@ -12,10 +12,10 @@ export function deleteTask(
input: DeleteTaskInput,
): ResultAsync<void, AppError> {
if (input.folderPath === undefined) {
return errAsync(AppError.noFolder());
return errAsync(appError(AppErrorVariant.NO_FOLDER));
}
if (input.id === undefined) {
return errAsync(AppError.noId());
return errAsync(appError(AppErrorVariant.NO_ID));
}
const folderPath = input.folderPath;
@@ -24,7 +24,7 @@ export function deleteTask(
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(existing) => {
if (!existing) {
return errAsync(AppError.notFound());
return errAsync(appError(AppErrorVariant.NOT_FOUND, { id }));
}
return ResultAsync.fromSafePromise(repo.delete(folderPath, id));
},
+7 -5
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { AppError } from "@/error";
import { appError, AppErrorVariant } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { openTask } from "./openTask";
@@ -34,12 +34,14 @@ describe("openTask", () => {
expect(result.value.task).toEqual(created);
});
it("returns not_found for unknown id", async () => {
it("returns not-found for unknown id", async () => {
const result = await openTask(repo, {
folderPath: folder,
id: "missing",
});
expect(result).toEqual(err(AppError.notFound()));
expect(result).toEqual(
err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })),
);
});
it("rejects missing folder", async () => {
@@ -47,7 +49,7 @@ describe("openTask", () => {
folderPath: undefined,
id: "x",
});
expect(result).toEqual(err(AppError.noFolder()));
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
});
it("rejects missing id", async () => {
@@ -55,6 +57,6 @@ describe("openTask", () => {
folderPath: folder,
id: undefined,
});
expect(result).toEqual(err(AppError.noId()));
expect(result).toEqual(err(appError(AppErrorVariant.NO_ID)));
});
});
+4 -4
View File
@@ -1,5 +1,5 @@
import { errAsync, okAsync, ResultAsync } from "neverthrow";
import { AppError } from "@/error";
import { appError, AppError, AppErrorVariant } from "@/error";
import { Task } from "@/model/task";
import { ITaskRepository } from "@/ports";
@@ -18,10 +18,10 @@ export function openTask(
input: OpenTaskInput,
): ResultAsync<OpenTaskValue, AppError> {
if (input.folderPath === undefined) {
return errAsync(AppError.noFolder());
return errAsync(appError(AppErrorVariant.NO_FOLDER));
}
if (input.id === undefined) {
return errAsync(AppError.noId());
return errAsync(appError(AppErrorVariant.NO_ID));
}
const folderPath = input.folderPath;
@@ -30,7 +30,7 @@ export function openTask(
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(task) => {
if (!task) {
return errAsync(AppError.notFound());
return errAsync(appError(AppErrorVariant.NOT_FOUND, { id }));
}
return okAsync({
path: repo.getFilePath(folderPath, task),
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { appError, AppErrorVariant, formatMessage } from "./error";
describe("formatMessage", () => {
it("substitutes placeholders", () => {
expect(formatMessage("Task not found: {id}", { id: "abc" })).toBe(
"Task not found: abc",
);
});
it("drops empty placeholders and cleans spacing", () => {
expect(formatMessage("Task not found: {id}", {})).toBe("Task not found:");
expect(formatMessage("Invalid message{detail}", {})).toBe(
"Invalid message",
);
expect(
formatMessage("Invalid message{detail}", { detail: ": explode" }),
).toBe("Invalid message: explode");
});
});
describe("appError", () => {
it("builds variant + message", () => {
expect(appError(AppErrorVariant.NOT_FOUND, { id: "x" })).toEqual({
variant: AppErrorVariant.NOT_FOUND,
message: "Task not found: x",
});
});
it("works without params", () => {
expect(appError(AppErrorVariant.NO_FOLDER)).toEqual({
variant: AppErrorVariant.NO_FOLDER,
message: "Task folder is not set",
});
});
});
+53 -16
View File
@@ -1,20 +1,57 @@
/** All application-level error codes. */
export type AppErrorCode =
| "no_folder"
| "no_id"
| "no_status"
| "empty_title"
| "not_found";
export const AppErrorVariant = {
NO_FOLDER: "no-folder",
NO_ID: "no-id",
NO_STATUS: "no-status",
NO_DESCRIPTION: "no-description",
EMPTY_TITLE: "empty-title",
NOT_FOUND: "not-found",
INVALID_MESSAGE: "invalid-message",
} as const;
export type AppErrorVariant =
(typeof AppErrorVariant)[keyof typeof AppErrorVariant];
/** Discriminated app error returned via neverthrow `Result` / `ResultAsync`. */
export type AppError = {
readonly code: AppErrorCode;
readonly variant: AppErrorVariant;
readonly message: string;
};
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;
export type AppErrorParams = Record<string, string | number | undefined>;
/** Message templates. Placeholders: `{name}` — replaced via `appError(variant, params)`. */
const APP_ERROR_MESSAGES: Record<AppErrorVariant, string> = {
[AppErrorVariant.NO_FOLDER]: "Task folder is not set",
[AppErrorVariant.NO_ID]: "Task id is required",
[AppErrorVariant.NO_STATUS]: "Task status is required",
[AppErrorVariant.NO_DESCRIPTION]: "Task description is required",
[AppErrorVariant.EMPTY_TITLE]: "Task title is empty",
[AppErrorVariant.NOT_FOUND]: "Task not found: {id}",
[AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}",
};
export function formatMessage(
template: string,
params: AppErrorParams = {},
): string {
return template
.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = params[key];
if (value === undefined || value === "") {
return "";
}
return String(value);
})
.replace(/[ \t]+/g, " ")
.replace(/\s+([:,;.])/g, "$1")
.trim();
}
export function appError(
variant: AppErrorVariant,
params?: AppErrorParams,
): AppError {
return {
variant,
message: formatMessage(APP_ERROR_MESSAGES[variant], params),
};
}