Skip to content

Commit 50fcb07

Browse files
committedJan 20, 2025··
tests: add for filters

File tree

9 files changed

+772
-48
lines changed

9 files changed

+772
-48
lines changed
 

‎apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPil
9898
);
9999
};
100100

101-
export const ControlCloud = () => {
101+
export const LogsControlCloud = () => {
102102
const { filters, removeFilter, updateFilters } = useFilters();
103103
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
104104

‎apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useCallback, useState } from "react";
44
import { LogsProvider } from "../context/logs";
55
import { LogsChart } from "./charts";
6-
import { ControlCloud } from "./control-cloud";
6+
import { LogsControlCloud } from "./control-cloud";
77
import { LogsControls } from "./controls";
88
import { LogDetails } from "./table/log-details";
99
import { LogsTable } from "./table/logs-table";
@@ -18,7 +18,7 @@ export const LogsClient = () => {
1818
return (
1919
<LogsProvider>
2020
<LogsControls />
21-
<ControlCloud />
21+
<LogsControlCloud />
2222
<LogsChart onMount={handleDistanceToTop} />
2323
<LogsTable />
2424
<LogDetails distanceToTop={tableDistanceToTop} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
filterFieldConfig,
4+
transformStructuredOutputToFilters,
5+
validateFieldValue,
6+
} from "./filters.schema";
7+
import type { FilterValue } from "./filters.type";
8+
9+
vi.stubGlobal("crypto", {
10+
randomUUID: vi.fn(() => "test-uuid"),
11+
});
12+
13+
describe("transformStructuredOutputToFilters", () => {
14+
it("should transform structured output to filters correctly", () => {
15+
const input = {
16+
filters: [
17+
{
18+
field: "status",
19+
filters: [{ operator: "is", value: 404 }],
20+
},
21+
{
22+
field: "paths",
23+
filters: [
24+
{ operator: "contains", value: "api" },
25+
{ operator: "startsWith", value: "/v1" },
26+
],
27+
},
28+
],
29+
};
30+
31+
//@ts-ignore
32+
const result = transformStructuredOutputToFilters(input, undefined);
33+
34+
expect(result).toHaveLength(3);
35+
expect(result[0]).toMatchObject({
36+
field: "status",
37+
operator: "is",
38+
value: 404,
39+
metadata: {
40+
colorClass: "bg-warning-8",
41+
},
42+
});
43+
expect(result[1]).toMatchObject({
44+
field: "paths",
45+
operator: "contains",
46+
value: "api",
47+
});
48+
expect(result[2]).toMatchObject({
49+
field: "paths",
50+
operator: "startsWith",
51+
value: "/v1",
52+
});
53+
});
54+
55+
it("should deduplicate filters with existing filters", () => {
56+
const existingFilters: FilterValue[] = [
57+
{
58+
id: "123",
59+
field: "status",
60+
operator: "is",
61+
value: 404,
62+
metadata: { colorClass: "bg-warning-8" },
63+
},
64+
];
65+
66+
const input = {
67+
filters: [
68+
{
69+
field: "status",
70+
filters: [{ operator: "is", value: 404 }],
71+
},
72+
{
73+
field: "paths",
74+
filters: [{ operator: "contains", value: "api" }],
75+
},
76+
],
77+
};
78+
79+
//@ts-ignore
80+
const result = transformStructuredOutputToFilters(input, existingFilters);
81+
82+
expect(result).toHaveLength(2);
83+
expect(result[1]).toMatchObject({
84+
field: "paths",
85+
operator: "contains",
86+
value: "api",
87+
});
88+
});
89+
});
90+
91+
describe("validateFieldValue", () => {
92+
it("should validate status codes correctly", () => {
93+
expect(validateFieldValue("status", 200)).toBe(true);
94+
expect(validateFieldValue("status", 404)).toBe(true);
95+
expect(validateFieldValue("status", 500)).toBe(true);
96+
expect(validateFieldValue("status", 600)).toBe(false);
97+
});
98+
99+
it("should validate HTTP methods correctly", () => {
100+
expect(validateFieldValue("methods", "GET")).toBe(true);
101+
expect(validateFieldValue("methods", "POST")).toBe(true);
102+
expect(validateFieldValue("methods", "INVALID")).toBe(false);
103+
});
104+
105+
it("should validate string fields correctly", () => {
106+
expect(validateFieldValue("paths", "/api/v1")).toBe(true);
107+
expect(validateFieldValue("host", "example.com")).toBe(true);
108+
expect(validateFieldValue("requestId", "req-123")).toBe(true);
109+
});
110+
111+
it("should validate number fields correctly", () => {
112+
expect(validateFieldValue("startTime", 1234567890)).toBe(true);
113+
expect(validateFieldValue("endTime", 1234567890)).toBe(true);
114+
});
115+
});
116+
117+
describe("filterFieldConfig", () => {
118+
it("should have correct status color classes", () => {
119+
expect(filterFieldConfig.status.getColorClass!(200)).toBe("bg-success-9");
120+
expect(filterFieldConfig.status.getColorClass!(404)).toBe("bg-warning-8");
121+
expect(filterFieldConfig.status.getColorClass!(500)).toBe("bg-error-9");
122+
});
123+
124+
it("should have correct operators for each field", () => {
125+
expect(filterFieldConfig.status.operators).toEqual(["is"]);
126+
expect(filterFieldConfig.paths.operators).toEqual(["is", "contains", "startsWith", "endsWith"]);
127+
expect(filterFieldConfig.host.operators).toEqual(["is"]);
128+
expect(filterFieldConfig.requestId.operators).toEqual(["is"]);
129+
});
130+
131+
it("should have correct field types", () => {
132+
expect(filterFieldConfig.status.type).toBe("number");
133+
expect(filterFieldConfig.methods.type).toBe("string");
134+
expect(filterFieldConfig.paths.type).toBe("string");
135+
expect(filterFieldConfig.host.type).toBe("string");
136+
});
137+
});

‎apps/dashboard/app/(app)/logs-v2/filters.schema.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ function isStringConfig(config: FieldConfig): config is StringConfig {
114114
return config.type === "string";
115115
}
116116

117-
function validateFieldValue(field: FilterField, value: string | number): boolean {
117+
export function validateFieldValue(field: FilterField, value: string | number): boolean {
118118
const config = filterFieldConfig[field];
119119

120120
if (isStatusConfig(config) && typeof value === "number") {
@@ -152,7 +152,7 @@ export const filterFieldConfig: FilterFieldConfigs = {
152152
}
153153
return "bg-success-9";
154154
},
155-
validate: (value) => value >= 100 && value <= 599,
155+
validate: (value) => value >= 200 && value <= 599,
156156
},
157157
methods: {
158158
type: "string",
@@ -165,7 +165,7 @@ export const filterFieldConfig: FilterFieldConfigs = {
165165
},
166166
host: {
167167
type: "string",
168-
operators: ["is", "contains"],
168+
operators: ["is"],
169169
},
170170
requestId: {
171171
type: "string",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { useQueryStates } from "nuqs";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { parseAsFilterValueArray, useFilters } from "./use-filters";
5+
6+
vi.mock("nuqs", () => {
7+
const mockSetSearchParams = vi.fn();
8+
9+
return {
10+
useQueryStates: vi.fn(() => [
11+
{
12+
status: null,
13+
methods: null,
14+
paths: null,
15+
host: null,
16+
requestId: null,
17+
startTime: null,
18+
endTime: null,
19+
},
20+
mockSetSearchParams,
21+
]),
22+
parseAsInteger: {
23+
parse: (str: string | null) => (str ? Number.parseInt(str) : null),
24+
serialize: (value: number | null) => value?.toString() ?? "",
25+
},
26+
};
27+
});
28+
29+
vi.stubGlobal("crypto", {
30+
randomUUID: vi.fn(() => "test-uuid"),
31+
});
32+
33+
const mockUseQueryStates = vi.mocked(useQueryStates);
34+
const mockSetSearchParams = vi.fn();
35+
36+
describe("parseAsFilterValueArray", () => {
37+
it("should return empty array for null input", () => {
38+
//@ts-expect-error ts yells for no reason
39+
expect(parseAsFilterValueArray.parse(null)).toEqual([]);
40+
});
41+
42+
it("should return empty array for empty string", () => {
43+
expect(parseAsFilterValueArray.parse("")).toEqual([]);
44+
});
45+
46+
it("should parse single filter correctly", () => {
47+
const result = parseAsFilterValueArray.parse("is:200");
48+
expect(result).toEqual([
49+
{
50+
operator: "is",
51+
value: "200",
52+
},
53+
]);
54+
});
55+
56+
it("should parse multiple filters correctly", () => {
57+
const result = parseAsFilterValueArray.parse("is:200,contains:error");
58+
expect(result).toEqual([
59+
{ operator: "is", value: "200" },
60+
{ operator: "contains", value: "error" },
61+
]);
62+
});
63+
64+
it("should return empty array for invalid operator", () => {
65+
expect(parseAsFilterValueArray.parse("invalid:200")).toEqual([]);
66+
});
67+
68+
it("should serialize empty array to empty string", () => {
69+
//@ts-expect-error ts yells for no reason
70+
expect(parseAsFilterValueArray.serialize([])).toBe("");
71+
});
72+
73+
it("should serialize array of filters correctly", () => {
74+
const filters = [
75+
{ operator: "is", value: "200" },
76+
{ operator: "contains", value: "error" },
77+
];
78+
//@ts-expect-error ts yells for no reason
79+
expect(parseAsFilterValueArray?.serialize(filters)).toBe("is:200,contains:error");
80+
});
81+
});
82+
83+
describe("useFilters hook", () => {
84+
beforeEach(() => {
85+
vi.clearAllMocks();
86+
mockUseQueryStates.mockImplementation(() => [
87+
{
88+
status: null,
89+
methods: null,
90+
paths: null,
91+
host: null,
92+
requestId: null,
93+
startTime: null,
94+
endTime: null,
95+
},
96+
mockSetSearchParams,
97+
]);
98+
});
99+
100+
it("should initialize with empty filters", () => {
101+
const { result } = renderHook(() => useFilters());
102+
expect(result.current.filters).toEqual([]);
103+
});
104+
105+
it("should initialize with existing filters", () => {
106+
mockUseQueryStates.mockImplementation(() => [
107+
{
108+
status: [{ operator: "is", value: "200" }],
109+
methods: null,
110+
paths: null,
111+
host: null,
112+
requestId: null,
113+
startTime: null,
114+
endTime: null,
115+
},
116+
mockSetSearchParams,
117+
]);
118+
119+
const { result } = renderHook(() => useFilters());
120+
expect(result.current.filters).toEqual([
121+
{
122+
id: "test-uuid",
123+
field: "status",
124+
operator: "is",
125+
value: "200",
126+
metadata: expect.any(Object),
127+
},
128+
]);
129+
});
130+
131+
it("should remove filter correctly", () => {
132+
mockUseQueryStates.mockImplementation(() => [
133+
{
134+
status: [{ operator: "is", value: "200" }],
135+
methods: null,
136+
paths: null,
137+
host: null,
138+
requestId: null,
139+
startTime: null,
140+
endTime: null,
141+
},
142+
mockSetSearchParams,
143+
]);
144+
145+
const { result } = renderHook(() => useFilters());
146+
147+
act(() => {
148+
result.current.removeFilter("test-uuid");
149+
});
150+
151+
expect(mockSetSearchParams).toHaveBeenCalledWith({
152+
status: null,
153+
methods: null,
154+
paths: null,
155+
host: null,
156+
requestId: null,
157+
startTime: null,
158+
endTime: null,
159+
});
160+
});
161+
162+
it("should handle multiple filters", () => {
163+
const { result } = renderHook(() => useFilters());
164+
165+
act(() => {
166+
result.current.updateFilters([
167+
{
168+
id: "test-uuid-1",
169+
field: "status",
170+
operator: "is",
171+
value: 200,
172+
},
173+
{
174+
id: "test-uuid-2",
175+
field: "methods",
176+
operator: "is",
177+
value: "GET",
178+
},
179+
]);
180+
});
181+
182+
expect(mockSetSearchParams).toHaveBeenCalledWith({
183+
status: [{ operator: "is", value: 200 }],
184+
methods: [{ operator: "is", value: "GET" }],
185+
paths: null,
186+
host: null,
187+
requestId: null,
188+
startTime: null,
189+
endTime: null,
190+
});
191+
});
192+
193+
it("should handle time range filters", () => {
194+
const { result } = renderHook(() => useFilters());
195+
const startTime = 1609459200000;
196+
197+
act(() => {
198+
result.current.updateFilters([
199+
{
200+
id: "test-uuid",
201+
field: "startTime",
202+
operator: "is",
203+
value: startTime,
204+
},
205+
]);
206+
});
207+
208+
expect(mockSetSearchParams).toHaveBeenCalledWith({
209+
status: null,
210+
methods: null,
211+
paths: null,
212+
host: null,
213+
requestId: null,
214+
startTime,
215+
endTime: null,
216+
});
217+
});
218+
219+
it("should handle complex filter operators", () => {
220+
const { result } = renderHook(() => useFilters());
221+
222+
act(() => {
223+
result.current.updateFilters([
224+
{
225+
id: "test-uuid-1",
226+
field: "paths",
227+
operator: "contains",
228+
value: "/api",
229+
},
230+
{
231+
id: "test-uuid-2",
232+
field: "host",
233+
operator: "startsWith",
234+
value: "test",
235+
},
236+
]);
237+
});
238+
239+
expect(mockSetSearchParams).toHaveBeenCalledWith({
240+
status: null,
241+
methods: null,
242+
paths: [{ operator: "contains", value: "/api" }],
243+
host: [{ operator: "startsWith", value: "test" }],
244+
requestId: null,
245+
startTime: null,
246+
endTime: null,
247+
});
248+
});
249+
250+
it("should handle clearing all filters", () => {
251+
mockUseQueryStates.mockImplementation(() => [
252+
{
253+
status: [{ operator: "is", value: "200" }],
254+
methods: [{ operator: "is", value: "GET" }],
255+
paths: null,
256+
host: null,
257+
requestId: null,
258+
startTime: null,
259+
endTime: null,
260+
},
261+
mockSetSearchParams,
262+
]);
263+
264+
const { result } = renderHook(() => useFilters());
265+
266+
act(() => {
267+
result.current.updateFilters([]);
268+
});
269+
270+
expect(mockSetSearchParams).toHaveBeenCalledWith({
271+
status: null,
272+
methods: null,
273+
paths: null,
274+
host: null,
275+
requestId: null,
276+
startTime: null,
277+
endTime: null,
278+
});
279+
});
280+
});

‎apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts

+1-26
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
ResponseStatus,
1212
} from "../filters.type";
1313

14-
const parseAsFilterValueArray: Parser<FilterUrlValue[]> = {
14+
export const parseAsFilterValueArray: Parser<FilterUrlValue[]> = {
1515
parse: (str: string | null) => {
1616
if (!str) {
1717
return [];
@@ -197,33 +197,8 @@ export const useFilters = () => {
197197
[filters, updateFilters],
198198
);
199199

200-
const addFilter = useCallback(
201-
(
202-
field: FilterField,
203-
operator: FilterOperator,
204-
value: string | number | ResponseStatus | HttpMethod,
205-
) => {
206-
const newFilter: FilterValue = {
207-
id: crypto.randomUUID(),
208-
field,
209-
operator,
210-
value,
211-
metadata:
212-
field === "status"
213-
? {
214-
colorClass: filterFieldConfig.status.getColorClass?.(value as number),
215-
}
216-
: undefined,
217-
};
218-
219-
updateFilters([...filters, newFilter]);
220-
},
221-
[filters, updateFilters],
222-
);
223-
224200
return {
225201
filters,
226-
addFilter,
227202
removeFilter,
228203
updateFilters,
229204
};

‎apps/dashboard/package.json

+7-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"dev": "next dev",
77
"build": "next build",
88
"start": "next start",
9-
"lint": "next lint"
9+
"lint": "next lint",
10+
"test": "vitest run"
1011
},
1112
"dependencies": {
1213
"@ant-design/plots": "^1.2.5",
@@ -107,14 +108,18 @@
107108
"devDependencies": {
108109
"@clerk/types": "^3.63.1",
109110
"@tailwindcss/aspect-ratio": "^0.4.2",
111+
"@testing-library/react": "^16.2.0",
112+
"@testing-library/react-hooks": "^8.0.1",
110113
"@types/d3-array": "^3.2.1",
111114
"@types/ms": "^0.7.34",
112115
"@types/node": "^20.14.9",
113116
"@types/react": "18.3.11",
114117
"@types/react-dom": "18.3.0",
115118
"autoprefixer": "^10.4.19",
119+
"jsdom": "^26.0.0",
116120
"postcss": "^8.4.38",
117121
"tailwindcss": "^3.4.3",
118-
"typescript": "^5.1.3"
122+
"typescript": "^5.1.3",
123+
"vitest": "^1.6.0"
119124
}
120125
}

‎apps/dashboard/vitest.config.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
environment: "jsdom",
6+
},
7+
});

‎pnpm-lock.yaml

+334-14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.