Skip to content

Commit c1f4139

Browse files
committed
strengthen tag filter coverage
The tag filter tests exercise backend matching directly, but they leave the raw request boundary, route translation, and mutation cache behavior unprotected. Regressions in those paths can silently ignore public API filters or leave filtered job lists stale. Assert repeated query parameter extraction and parser type dispatch. Add a focused route component harness that verifies tags flow from route search state into the job query and filter control, back into navigation updates, and through the cache keys refreshed after cancel, delete, and retry.
1 parent b9f3f97 commit c1f4139

4 files changed

Lines changed: 241 additions & 2 deletions

File tree

handler_api_endpoint_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"log/slog"
77
"net/http"
8+
"net/http/httptest"
89
"testing"
910
"time"
1011

@@ -761,6 +762,21 @@ func TestAPIHandlerJobListCustomSchema(t *testing.T) {
761762
require.Equal(t, job.ID, resp.Data[0].ID)
762763
}
763764

765+
func TestJobListRequestExtractRaw(t *testing.T) {
766+
t.Parallel()
767+
768+
req := httptest.NewRequestWithContext(
769+
t.Context(),
770+
http.MethodGet,
771+
"/api/jobs?tags=ALPHA&tags=customer%3A123",
772+
nil,
773+
)
774+
params := &jobListRequest{}
775+
776+
require.NoError(t, params.ExtractRaw(req))
777+
require.Equal(t, []string{"ALPHA", "customer:123"}, params.Tags)
778+
}
779+
764780
func TestAPIHandlerJobRetry(t *testing.T) {
765781
t.Parallel()
766782

src/components/job-search/parser.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe("parser", () => {
2121
expect(result[1].match).toBe("queue:");
2222
expect(result[1].values).toEqual(["priority"]);
2323
expect(result[2].match).toBe("tags:");
24+
expect(result[2].typeId).toBe(JobFilterTypeID.TAGS);
2425
expect(result[2].values).toEqual(["customer", "urgent"]);
2526
});
2627

src/routes/jobs/index.test.tsx

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,129 @@
1+
import { Filter, FilterTypeId } from "@components/job-search/JobSearch";
2+
import { JobsIndexComponent, Route } from "@routes/jobs/index";
13
import { jobSearchSchema } from "@routes/jobs/index.schema";
4+
import { listJobsKey } from "@services/jobs";
25
import { JobState } from "@services/types";
3-
import { describe, expect, it } from "vitest";
6+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
7+
import { act, render, waitFor } from "@testing-library/react";
8+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9+
10+
type JobListHarnessProps = {
11+
cancelJobs: (jobIDs: bigint[]) => void;
12+
deleteJobs: (jobIDs: bigint[]) => void;
13+
initialFilters?: Filter[];
14+
onFiltersChange?: (filters: Filter[]) => void;
15+
retryJobs: (jobIDs: bigint[]) => void;
16+
};
17+
18+
type NavigateOptions = {
19+
search: (
20+
old: Record<string, JobState | string[] | undefined>,
21+
) => Record<string, JobState | string[] | undefined>;
22+
};
23+
24+
const {
25+
mockCancelJobs,
26+
mockCountsByState,
27+
mockDeleteJobs,
28+
mockJobList,
29+
mockListJobs,
30+
mockNavigate,
31+
mockUseRetryJobs,
32+
} = vi.hoisted(() => ({
33+
mockCancelJobs: vi.fn(),
34+
mockCountsByState: vi.fn(),
35+
mockDeleteJobs: vi.fn(),
36+
mockJobList: vi.fn(),
37+
mockListJobs: vi.fn(),
38+
mockNavigate: vi.fn(),
39+
mockUseRetryJobs: vi.fn(),
40+
}));
41+
42+
vi.mock("@components/JobList", () => ({
43+
default: (props: JobListHarnessProps) => {
44+
mockJobList(props);
45+
return null;
46+
},
47+
}));
48+
49+
vi.mock("@contexts/RefreshSettings.hook", () => ({
50+
useRefreshSetting: () => ({ intervalMs: 0 }),
51+
}));
52+
53+
vi.mock("@hooks/use-retry-jobs", () => ({
54+
useRetryJobs: (opts: { onSuccess: () => void }) => {
55+
mockUseRetryJobs(opts);
56+
return { mutate: (_jobIDs: bigint[]) => opts.onSuccess() };
57+
},
58+
}));
59+
60+
vi.mock("@services/jobs", async (importOriginal) => {
61+
const actual = await importOriginal<typeof import("@services/jobs")>();
62+
return {
63+
...actual,
64+
cancelJobs: mockCancelJobs,
65+
deleteJobs: mockDeleteJobs,
66+
listJobs: mockListJobs,
67+
};
68+
});
69+
70+
vi.mock("@services/states", async (importOriginal) => {
71+
const actual = await importOriginal<typeof import("@services/states")>();
72+
return {
73+
...actual,
74+
countsByState: mockCountsByState,
75+
};
76+
});
77+
78+
vi.mock("@services/toast", () => ({
79+
toastError: vi.fn(),
80+
}));
81+
82+
const loaderDeps = {
83+
id: undefined,
84+
kind: ["email"],
85+
limit: 20,
86+
priority: [1],
87+
queue: ["default"],
88+
state: JobState.Running,
89+
tags: ["customer:123", "urgent"],
90+
};
91+
92+
const activeJobsKey = listJobsKey({
93+
ids: loaderDeps.id,
94+
kinds: loaderDeps.kind,
95+
limit: loaderDeps.limit,
96+
priorities: loaderDeps.priority,
97+
queues: loaderDeps.queue,
98+
state: loaderDeps.state,
99+
tags: loaderDeps.tags,
100+
});
101+
102+
const latestJobListProps = (): JobListHarnessProps => {
103+
const props = mockJobList.mock.calls.at(-1)?.[0] as
104+
JobListHarnessProps | undefined;
105+
expect(props).toBeDefined();
106+
if (!props) throw new Error("JobList was not rendered");
107+
return props;
108+
};
109+
110+
const renderJobsIndex = () => {
111+
const queryClient = new QueryClient({
112+
defaultOptions: {
113+
mutations: { retry: false },
114+
queries: { retry: false },
115+
},
116+
});
117+
118+
return {
119+
queryClient,
120+
...render(
121+
<QueryClientProvider client={queryClient}>
122+
<JobsIndexComponent />
123+
</QueryClientProvider>,
124+
),
125+
};
126+
};
4127

5128
describe("Jobs Route Search Schema", () => {
6129
it("validates search parameters correctly", () => {
@@ -67,3 +190,102 @@ describe("Jobs Route Search Schema", () => {
67190
});
68191
});
69192
});
193+
194+
describe("JobsIndexComponent", () => {
195+
beforeEach(() => {
196+
vi.clearAllMocks();
197+
mockCancelJobs.mockResolvedValue(undefined);
198+
mockCountsByState.mockResolvedValue({});
199+
mockDeleteJobs.mockResolvedValue(undefined);
200+
mockListJobs.mockResolvedValue([]);
201+
vi.spyOn(Route, "useLoaderDeps").mockReturnValue(loaderDeps);
202+
vi.spyOn(Route, "useNavigate").mockReturnValue(mockNavigate);
203+
});
204+
205+
afterEach(() => {
206+
vi.restoreAllMocks();
207+
});
208+
209+
it("round trips tags between route search and job filters", async () => {
210+
renderJobsIndex();
211+
212+
await waitFor(() => expect(mockListJobs).toHaveBeenCalled());
213+
expect(mockListJobs.mock.calls.at(-1)?.[0]).toMatchObject({
214+
queryKey: activeJobsKey,
215+
});
216+
217+
const props = latestJobListProps();
218+
expect(props.initialFilters).toEqual(
219+
expect.arrayContaining([
220+
{
221+
id: "tags-filter",
222+
match: "tags:",
223+
typeId: FilterTypeId.TAGS,
224+
values: loaderDeps.tags,
225+
},
226+
]),
227+
);
228+
229+
act(() => {
230+
props.onFiltersChange?.([
231+
{
232+
id: "replacement-tags",
233+
match: "tags:",
234+
typeId: FilterTypeId.TAGS,
235+
values: ["replacement"],
236+
},
237+
]);
238+
});
239+
let navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as
240+
NavigateOptions | undefined;
241+
expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({
242+
tags: ["replacement"],
243+
});
244+
245+
act(() => props.onFiltersChange?.([]));
246+
navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as
247+
NavigateOptions | undefined;
248+
expect(
249+
navigateOpts?.search({
250+
state: JobState.Running,
251+
tags: loaderDeps.tags,
252+
}),
253+
).toEqual({
254+
id: undefined,
255+
kind: undefined,
256+
priority: undefined,
257+
queue: undefined,
258+
state: JobState.Running,
259+
tags: undefined,
260+
});
261+
});
262+
263+
it("refreshes the active tag-filtered query after mutations", async () => {
264+
const { queryClient } = renderJobsIndex();
265+
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
266+
const removeQueries = vi.spyOn(queryClient, "removeQueries");
267+
268+
await waitFor(() => expect(mockListJobs).toHaveBeenCalled());
269+
const props = latestJobListProps();
270+
271+
act(() => props.cancelJobs([123n]));
272+
await waitFor(() =>
273+
expect(invalidateQueries).toHaveBeenCalledWith({
274+
queryKey: activeJobsKey,
275+
}),
276+
);
277+
278+
act(() => props.deleteJobs([123n]));
279+
await waitFor(() =>
280+
expect(removeQueries).toHaveBeenCalledWith({
281+
queryKey: activeJobsKey,
282+
}),
283+
);
284+
285+
invalidateQueries.mockClear();
286+
act(() => props.retryJobs([123n]));
287+
expect(invalidateQueries).toHaveBeenCalledWith({
288+
queryKey: activeJobsKey,
289+
});
290+
});
291+
});

src/routes/jobs/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export const Route = createFileRoute("/jobs/")({
8585
component: JobsIndexComponent,
8686
});
8787

88-
function JobsIndexComponent() {
88+
export function JobsIndexComponent() {
8989
const navigate = Route.useNavigate();
9090
const { id, limit, state, kind, queue, priority, tags } =
9191
Route.useLoaderDeps();

0 commit comments

Comments
 (0)