refactor: updater errors
This commit is contained in:
+5
-5
@@ -44,7 +44,7 @@ updated: 2026-07-12T10:00:00Z
|
||||
src/
|
||||
├── extension.ts # composition root (DI, register)
|
||||
├── ports.ts # IFileSystem, ITaskRepository, IConfigProvider
|
||||
├── error.ts # AppError + factories (единый каталог ошибок)
|
||||
├── error.ts # AppErrorVariant + AppError { variant, message }
|
||||
├── model/task.ts
|
||||
├── storage/
|
||||
│ ├── FsTaskRepository.ts
|
||||
@@ -132,10 +132,10 @@ use-cases + `ITaskRepository`. Ошибки — `AppError` из `error.ts`.
|
||||
|
||||
### neverthrow + `AppError`
|
||||
|
||||
- Use-cases возвращают `ResultAsync<T, AppError>` вместо кастомных
|
||||
`{ ok, reason }`.
|
||||
- Каталог ошибок: `src/error.ts` (`AppErrorCode`, factories `AppError.noFolder()`
|
||||
и т.д.).
|
||||
- Use-cases возвращают `ResultAsync<T, AppError>`.
|
||||
- `AppErrorVariant` — const-object кодов (`NOT_FOUND: "not-found"`).
|
||||
- `AppError` — `{ variant, message }`; `message` из шаблона с `{placeholders}`
|
||||
через `appError(variant, params?)`.
|
||||
|
||||
### Прочее относительно v0
|
||||
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user