Skip to content

Commit 6381c7f

Browse files
jamiebrynes7claude
andcommitted
plugin: add tests for API client, repository, hydration, errors, and data index
Extract error mapping, task hydration, and subscription types into dedicated modules to make them independently testable. Introduce shared test factories to reduce duplication across test files. Polyfill Array.prototype.remove in vitest setup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7ab5b7d commit 6381c7f

21 files changed

+1014
-263
lines changed

plugin/src/api/index.test.ts

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { RequestParams, WebFetcher, WebResponse } from "@/api/fetcher";
4+
import { TodoistApiClient, TodoistApiError } from "@/api/index";
5+
6+
function parseUrl(url: string) {
7+
const parsed = new URL(url);
8+
return { pathname: parsed.pathname, params: parsed.searchParams };
9+
}
10+
11+
function makeTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
12+
return {
13+
id: "123",
14+
added_at: "2024-01-01T00:00:00Z",
15+
content: "Test task",
16+
description: "",
17+
project_id: "456",
18+
section_id: null,
19+
parent_id: null,
20+
labels: [],
21+
priority: 1,
22+
due: null,
23+
duration: null,
24+
deadline: null,
25+
child_order: 0,
26+
...overrides,
27+
};
28+
}
29+
30+
function makePaginatedResponse(
31+
tasks: Record<string, unknown>[],
32+
nextCursor: string | null = null,
33+
): WebResponse {
34+
return {
35+
statusCode: 200,
36+
body: JSON.stringify({
37+
results: tasks,
38+
next_cursor: nextCursor,
39+
}),
40+
};
41+
}
42+
43+
function makeFetcher(): WebFetcher & {
44+
fetch: ReturnType<typeof vi.fn<(params: RequestParams) => Promise<WebResponse>>>;
45+
} {
46+
return { fetch: vi.fn<(params: RequestParams) => Promise<WebResponse>>() };
47+
}
48+
49+
describe("TodoistApiClient", () => {
50+
describe("getTasks", () => {
51+
it("calls /tasks endpoint when no filter is provided", async () => {
52+
const fetcher = makeFetcher();
53+
fetcher.fetch.mockResolvedValueOnce(makePaginatedResponse([makeTask()]));
54+
55+
const client = new TodoistApiClient("test-token", fetcher);
56+
const tasks = await client.getTasks();
57+
58+
expect(tasks).toHaveLength(1);
59+
expect(tasks[0].id).toBe("123");
60+
61+
const call = fetcher.fetch.mock.calls[0][0];
62+
const { pathname } = parseUrl(call.url);
63+
expect(pathname).toBe("/api/v1/tasks");
64+
});
65+
66+
it("calls /tasks/filter with query param when filter is provided", async () => {
67+
const fetcher = makeFetcher();
68+
fetcher.fetch.mockResolvedValueOnce(makePaginatedResponse([makeTask()]));
69+
70+
const client = new TodoistApiClient("test-token", fetcher);
71+
await client.getTasks("today");
72+
73+
const call = fetcher.fetch.mock.calls[0][0];
74+
const { pathname, params } = parseUrl(call.url);
75+
expect(pathname).toBe("/api/v1/tasks/filter");
76+
expect(params.get("query")).toBe("today");
77+
});
78+
});
79+
80+
describe("pagination", () => {
81+
it("returns results from a single page when nextCursor is null", async () => {
82+
const fetcher = makeFetcher();
83+
fetcher.fetch.mockResolvedValueOnce(
84+
makePaginatedResponse([makeTask(), makeTask({ id: "456" })]),
85+
);
86+
87+
const client = new TodoistApiClient("test-token", fetcher);
88+
const tasks = await client.getTasks();
89+
90+
expect(tasks).toHaveLength(2);
91+
expect(fetcher.fetch).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it("follows pagination cursor across multiple pages", async () => {
95+
const fetcher = makeFetcher();
96+
fetcher.fetch
97+
.mockResolvedValueOnce(makePaginatedResponse([makeTask({ id: "1" })], "cursor-abc"))
98+
.mockResolvedValueOnce(makePaginatedResponse([makeTask({ id: "2" })]));
99+
100+
const client = new TodoistApiClient("test-token", fetcher);
101+
const tasks = await client.getTasks();
102+
103+
expect(tasks).toHaveLength(2);
104+
expect(tasks[0].id).toBe("1");
105+
expect(tasks[1].id).toBe("2");
106+
expect(fetcher.fetch).toHaveBeenCalledTimes(2);
107+
108+
const secondCall = fetcher.fetch.mock.calls[1][0];
109+
const { params } = parseUrl(secondCall.url);
110+
expect(params.get("cursor")).toBe("cursor-abc");
111+
});
112+
113+
it("preserves filter query params across paginated requests", async () => {
114+
const fetcher = makeFetcher();
115+
fetcher.fetch
116+
.mockResolvedValueOnce(makePaginatedResponse([makeTask({ id: "1" })], "cursor-1"))
117+
.mockResolvedValueOnce(makePaginatedResponse([makeTask({ id: "2" })]));
118+
119+
const client = new TodoistApiClient("test-token", fetcher);
120+
await client.getTasks("today");
121+
122+
const firstCall = fetcher.fetch.mock.calls[0][0];
123+
const firstParams = parseUrl(firstCall.url).params;
124+
expect(firstParams.get("query")).toBe("today");
125+
126+
const secondCall = fetcher.fetch.mock.calls[1][0];
127+
const secondParams = parseUrl(secondCall.url).params;
128+
expect(secondParams.get("query")).toBe("today");
129+
expect(secondParams.get("cursor")).toBe("cursor-1");
130+
});
131+
132+
it("returns empty array when results are empty", async () => {
133+
const fetcher = makeFetcher();
134+
fetcher.fetch.mockResolvedValueOnce(makePaginatedResponse([]));
135+
136+
const client = new TodoistApiClient("test-token", fetcher);
137+
const tasks = await client.getTasks();
138+
139+
expect(tasks).toHaveLength(0);
140+
});
141+
});
142+
143+
describe("createTask", () => {
144+
it("sends POST with correct body serialization including options", async () => {
145+
const fetcher = makeFetcher();
146+
fetcher.fetch.mockResolvedValueOnce({
147+
statusCode: 200,
148+
body: JSON.stringify(makeTask({ content: "New task", project_id: "proj-1", priority: 4 })),
149+
});
150+
151+
const client = new TodoistApiClient("test-token", fetcher);
152+
const task = await client.createTask("New task", {
153+
projectId: "proj-1",
154+
priority: 4,
155+
});
156+
157+
expect(task.content).toBe("New task");
158+
159+
const call = fetcher.fetch.mock.calls[0][0];
160+
expect(call.method).toBe("POST");
161+
const { pathname } = parseUrl(call.url);
162+
expect(pathname).toBe("/api/v1/tasks");
163+
expect(call.headers["Content-Type"]).toBe("application/json");
164+
165+
const body = JSON.parse(call.body as string);
166+
expect(body.content).toBe("New task");
167+
expect(body.project_id).toBe("proj-1");
168+
expect(body.priority).toBe(4);
169+
});
170+
171+
it("sends POST with only content when no options provided", async () => {
172+
const fetcher = makeFetcher();
173+
fetcher.fetch.mockResolvedValueOnce({
174+
statusCode: 200,
175+
body: JSON.stringify(makeTask({ content: "Simple task" })),
176+
});
177+
178+
const client = new TodoistApiClient("test-token", fetcher);
179+
await client.createTask("Simple task");
180+
181+
const call = fetcher.fetch.mock.calls[0][0];
182+
const body = JSON.parse(call.body as string);
183+
expect(body.content).toBe("Simple task");
184+
expect(Object.keys(body)).toEqual(["content"]);
185+
});
186+
});
187+
188+
describe("closeTask", () => {
189+
it("sends POST to /tasks/{id}/close without body or Content-Type", async () => {
190+
const fetcher = makeFetcher();
191+
fetcher.fetch.mockResolvedValueOnce({ statusCode: 204, body: "" });
192+
193+
const client = new TodoistApiClient("test-token", fetcher);
194+
await client.closeTask("task-789");
195+
196+
const call = fetcher.fetch.mock.calls[0][0];
197+
expect(call.method).toBe("POST");
198+
const { pathname } = parseUrl(call.url);
199+
expect(pathname).toBe("/api/v1/tasks/task-789/close");
200+
expect(call.body).toBeUndefined();
201+
expect(call.headers["Content-Type"]).toBeUndefined();
202+
});
203+
});
204+
205+
describe("getUser", () => {
206+
it("calls /user endpoint and parses response", async () => {
207+
const fetcher = makeFetcher();
208+
fetcher.fetch.mockResolvedValueOnce({
209+
statusCode: 200,
210+
body: JSON.stringify({ is_premium: true }),
211+
});
212+
213+
const client = new TodoistApiClient("test-token", fetcher);
214+
const user = await client.getUser();
215+
216+
expect(user.isPremium).toBe(true);
217+
218+
const call = fetcher.fetch.mock.calls[0][0];
219+
expect(call.method).toBe("GET");
220+
const { pathname } = parseUrl(call.url);
221+
expect(pathname).toBe("/api/v1/user");
222+
});
223+
});
224+
225+
describe("sync", () => {
226+
it("calls /sync with snakified query params", async () => {
227+
const fetcher = makeFetcher();
228+
fetcher.fetch.mockResolvedValueOnce({
229+
statusCode: 200,
230+
body: JSON.stringify({
231+
sync_token: "new-token",
232+
projects: [],
233+
sections: [],
234+
labels: [],
235+
}),
236+
});
237+
238+
const client = new TodoistApiClient("test-token", fetcher);
239+
const result = await client.sync("old-token");
240+
241+
expect(result.syncToken).toBe("new-token");
242+
243+
const call = fetcher.fetch.mock.calls[0][0];
244+
expect(call.method).toBe("POST");
245+
const { params } = parseUrl(call.url);
246+
expect(params.get("sync_token")).toBe("old-token");
247+
expect(params.get("resource_types")).not.toBeNull();
248+
});
249+
});
250+
251+
describe("error handling", () => {
252+
it("throws TodoistApiError with correct statusCode on 4xx", async () => {
253+
const fetcher = makeFetcher();
254+
fetcher.fetch.mockResolvedValueOnce({
255+
statusCode: 401,
256+
body: "Unauthorized",
257+
});
258+
259+
const client = new TodoistApiClient("test-token", fetcher);
260+
await expect(client.getTasks()).rejects.toSatisfy((e) => {
261+
expect(e).toBeInstanceOf(TodoistApiError);
262+
expect((e as TodoistApiError).statusCode).toBe(401);
263+
return true;
264+
});
265+
});
266+
267+
it("throws TodoistApiError with correct statusCode on 5xx", async () => {
268+
const fetcher = makeFetcher();
269+
fetcher.fetch.mockResolvedValueOnce({
270+
statusCode: 500,
271+
body: "Internal Server Error",
272+
});
273+
274+
const client = new TodoistApiClient("test-token", fetcher);
275+
await expect(client.getTasks()).rejects.toSatisfy((e) => {
276+
expect(e).toBeInstanceOf(TodoistApiError);
277+
expect((e as TodoistApiError).statusCode).toBe(500);
278+
return true;
279+
});
280+
});
281+
});
282+
283+
describe("authorization", () => {
284+
it("includes Bearer token in Authorization header for all requests", async () => {
285+
const fetcher = makeFetcher();
286+
fetcher.fetch.mockResolvedValueOnce(makePaginatedResponse([makeTask()]));
287+
288+
const client = new TodoistApiClient("my-secret-token", fetcher);
289+
await client.getTasks();
290+
291+
const call = fetcher.fetch.mock.calls[0][0];
292+
expect(call.headers.Authorization).toBe("Bearer my-secret-token");
293+
});
294+
});
295+
});

plugin/src/data/deadline.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
33

44
import type { Deadline as ApiDeadline } from "@/api/domain/task";
55
import { Deadline, type DeadlineInfo } from "@/data/deadline";
6+
import { makeDate } from "@/factories/data";
67

78
vi.mock("../infra/time.ts", () => {
89
return {
@@ -17,9 +18,6 @@ vi.mock("../infra/locale.ts", () => {
1718
};
1819
});
1920

20-
const makeDate = (year: number, month: number, day: number): Date =>
21-
new Date(Date.UTC(year, month, day, 0, 0));
22-
2321
describe("parse", () => {
2422
type TestCase = {
2523
description: string;

plugin/src/data/dueDate.test.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
44
import type { DueDate as ApiDueDate } from "@/api/domain/dueDate";
55
import type { Duration as ApiDuration } from "@/api/domain/task";
66
import { DueDate } from "@/data/dueDate";
7+
import { makeDate } from "@/factories/data";
78

89
vi.mock("../infra/time.ts", () => {
910
return {
@@ -19,14 +20,6 @@ vi.mock("../infra/locale.ts", () => {
1920
};
2021
});
2122

22-
const makeDate = (
23-
year: number,
24-
month: number,
25-
day: number,
26-
hours?: number,
27-
minutes?: number,
28-
): Date => new Date(Date.UTC(year, month, day, hours ?? 0, minutes ?? 0));
29-
3023
describe("parse", () => {
3124
type TestCase = {
3225
description: string;

plugin/src/data/errors.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { TodoistApiError } from "@/api";
4+
import type { RequestParams, WebResponse } from "@/api/fetcher";
5+
import { mapApiError, QueryErrorKind } from "@/data/errors";
6+
7+
const makeRequest = (): RequestParams => ({
8+
url: "https://api.todoist.com/api/v1/tasks",
9+
method: "GET",
10+
headers: {},
11+
});
12+
13+
const makeResponse = (statusCode: number): WebResponse => ({
14+
statusCode,
15+
body: "error",
16+
});
17+
18+
describe("mapApiError", () => {
19+
it("should map BadRequest (400) to QueryErrorKind.BadRequest", () => {
20+
const error = new TodoistApiError(makeRequest(), makeResponse(400));
21+
expect(mapApiError(error)).toBe(QueryErrorKind.BadRequest);
22+
});
23+
24+
it("should map Unauthorized (401) to QueryErrorKind.Unauthorized", () => {
25+
const error = new TodoistApiError(makeRequest(), makeResponse(401));
26+
expect(mapApiError(error)).toBe(QueryErrorKind.Unauthorized);
27+
});
28+
29+
it("should map Forbidden (403) to QueryErrorKind.Forbidden", () => {
30+
const error = new TodoistApiError(makeRequest(), makeResponse(403));
31+
expect(mapApiError(error)).toBe(QueryErrorKind.Forbidden);
32+
});
33+
34+
it("should map ServerError (500) to QueryErrorKind.ServerError", () => {
35+
const error = new TodoistApiError(makeRequest(), makeResponse(500));
36+
expect(mapApiError(error)).toBe(QueryErrorKind.ServerError);
37+
});
38+
39+
it("should map other 5xx errors (502) to QueryErrorKind.ServerError", () => {
40+
const error = new TodoistApiError(makeRequest(), makeResponse(502));
41+
expect(mapApiError(error)).toBe(QueryErrorKind.ServerError);
42+
});
43+
44+
it("should map non-TodoistApiError to QueryErrorKind.Unknown", () => {
45+
expect(mapApiError(new Error("network failure"))).toBe(QueryErrorKind.Unknown);
46+
});
47+
});

0 commit comments

Comments
 (0)