Skip to content

Commit fdcf65c

Browse files
betegonclaude
andcommitted
test(dashboard): add unit tests for widget add, edit, and delete commands
Add 18 tests across 3 files covering: - widget add: API args, JSON/human output, validation (title, display, aggregate), auto-default orderby - widget edit: edit by index/title (case-insensitive), validation (index/title required, invalid aggregate, dataset change) - widget delete: delete by index/title, validation (index/title required, out-of-range), human output Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2135d10 commit fdcf65c

File tree

3 files changed

+626
-0
lines changed

3 files changed

+626
-0
lines changed
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* Dashboard Widget Add Command Tests
3+
*
4+
* Tests for the widget add command in src/commands/dashboard/widget/add.ts.
5+
* Uses spyOn pattern to mock API client and resolve-target.
6+
*/
7+
8+
import {
9+
afterEach,
10+
beforeEach,
11+
describe,
12+
expect,
13+
mock,
14+
spyOn,
15+
test,
16+
} from "bun:test";
17+
18+
import { addCommand } from "../../../../src/commands/dashboard/widget/add.js";
19+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
20+
import * as apiClient from "../../../../src/lib/api-client.js";
21+
import { ValidationError } from "../../../../src/lib/errors.js";
22+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
23+
import * as resolveTarget from "../../../../src/lib/resolve-target.js";
24+
import type { DashboardDetail } from "../../../../src/types/dashboard.js";
25+
26+
// ---------------------------------------------------------------------------
27+
// Helpers
28+
// ---------------------------------------------------------------------------
29+
30+
function createMockContext(cwd = "/tmp") {
31+
const stdoutWrite = mock(() => true);
32+
return {
33+
context: {
34+
stdout: { write: stdoutWrite },
35+
stderr: { write: mock(() => true) },
36+
cwd,
37+
setContext: mock(() => {
38+
// no-op for test
39+
}),
40+
},
41+
stdoutWrite,
42+
};
43+
}
44+
45+
// ---------------------------------------------------------------------------
46+
// Test data
47+
// ---------------------------------------------------------------------------
48+
49+
const sampleDashboard: DashboardDetail = {
50+
id: "123",
51+
title: "My Dashboard",
52+
widgets: [
53+
{
54+
title: "Error Count",
55+
displayType: "big_number",
56+
widgetType: "spans",
57+
queries: [
58+
{
59+
name: "",
60+
conditions: "",
61+
columns: [],
62+
aggregates: ["count()"],
63+
fields: ["count()"],
64+
},
65+
],
66+
layout: { x: 0, y: 0, w: 2, h: 1 },
67+
},
68+
{
69+
title: "Slow Spans",
70+
displayType: "table",
71+
widgetType: "spans",
72+
queries: [
73+
{
74+
name: "",
75+
conditions: "",
76+
columns: ["span.description"],
77+
aggregates: ["p95(span.duration)", "count()"],
78+
fields: ["span.description", "p95(span.duration)", "count()"],
79+
},
80+
],
81+
layout: { x: 2, y: 0, w: 4, h: 2 },
82+
},
83+
],
84+
dateCreated: "2026-03-01T10:00:00Z",
85+
};
86+
87+
// ---------------------------------------------------------------------------
88+
// Tests
89+
// ---------------------------------------------------------------------------
90+
91+
describe("dashboard widget add", () => {
92+
let getDashboardSpy: ReturnType<typeof spyOn>;
93+
let updateDashboardSpy: ReturnType<typeof spyOn>;
94+
let resolveOrgSpy: ReturnType<typeof spyOn>;
95+
96+
beforeEach(() => {
97+
getDashboardSpy = spyOn(apiClient, "getDashboard");
98+
updateDashboardSpy = spyOn(apiClient, "updateDashboard");
99+
resolveOrgSpy = spyOn(resolveTarget, "resolveOrg");
100+
101+
// Default mocks
102+
resolveOrgSpy.mockResolvedValue({ org: "acme-corp" });
103+
getDashboardSpy.mockResolvedValue(sampleDashboard);
104+
updateDashboardSpy.mockImplementation(async (_org, _id, body) => ({
105+
...sampleDashboard,
106+
widgets: body.widgets,
107+
}));
108+
});
109+
110+
afterEach(() => {
111+
getDashboardSpy.mockRestore();
112+
updateDashboardSpy.mockRestore();
113+
resolveOrgSpy.mockRestore();
114+
});
115+
116+
test("adds widget with correct API args (getDashboard then updateDashboard)", async () => {
117+
const { context } = createMockContext();
118+
const func = await addCommand.loader();
119+
await func.call(
120+
context,
121+
{ json: false, display: "line", query: ["count"] },
122+
"123",
123+
"New Widget"
124+
);
125+
126+
expect(getDashboardSpy).toHaveBeenCalledWith("acme-corp", "123");
127+
expect(updateDashboardSpy).toHaveBeenCalledWith(
128+
"acme-corp",
129+
"123",
130+
expect.objectContaining({
131+
title: "My Dashboard",
132+
widgets: expect.arrayContaining([
133+
expect.objectContaining({ title: "New Widget", displayType: "line" }),
134+
]),
135+
})
136+
);
137+
// Original widgets should be preserved plus the new one
138+
const body = updateDashboardSpy.mock.calls[0]?.[2];
139+
expect(body.widgets.length).toBe(3);
140+
});
141+
142+
test("JSON output contains dashboard, widget, and url", async () => {
143+
const { context, stdoutWrite } = createMockContext();
144+
const func = await addCommand.loader();
145+
await func.call(
146+
context,
147+
{ json: true, display: "big_number", query: ["count"] },
148+
"123",
149+
"My Counter"
150+
);
151+
152+
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
153+
const parsed = JSON.parse(output);
154+
expect(parsed.dashboard).toBeDefined();
155+
expect(parsed.widget).toBeDefined();
156+
expect(parsed.widget.title).toBe("My Counter");
157+
expect(parsed.url).toContain("dashboard/123");
158+
});
159+
160+
test("human output contains 'Added widget' and title", async () => {
161+
const { context, stdoutWrite } = createMockContext();
162+
const func = await addCommand.loader();
163+
await func.call(
164+
context,
165+
{ json: false, display: "line", query: ["count"] },
166+
"123",
167+
"Error Rate"
168+
);
169+
170+
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
171+
expect(output).toContain("Added widget");
172+
expect(output).toContain("Error Rate");
173+
});
174+
175+
test("throws ValidationError when title is missing (less than 2 positional args)", async () => {
176+
const { context } = createMockContext();
177+
const func = await addCommand.loader();
178+
179+
const err = await func
180+
.call(context, { json: false, display: "line" }, "123")
181+
.catch((e: Error) => e);
182+
expect(err).toBeInstanceOf(ValidationError);
183+
expect(err.message).toContain("Widget title is required");
184+
});
185+
186+
test("throws ValidationError for invalid display type", async () => {
187+
const { context } = createMockContext();
188+
const func = await addCommand.loader();
189+
190+
const err = await func
191+
.call(
192+
context,
193+
{ json: false, display: "invalid_type" },
194+
"123",
195+
"Bad Widget"
196+
)
197+
.catch((e: Error) => e);
198+
expect(err).toBeInstanceOf(ValidationError);
199+
expect(err.message).toContain("Invalid --display");
200+
});
201+
202+
test("throws ValidationError for invalid aggregate function", async () => {
203+
const { context } = createMockContext();
204+
const func = await addCommand.loader();
205+
206+
const err = await func
207+
.call(
208+
context,
209+
{ json: false, display: "line", query: ["not_a_function"] },
210+
"123",
211+
"Bad Widget"
212+
)
213+
.catch((e: Error) => e);
214+
expect(err).toBeInstanceOf(ValidationError);
215+
expect(err.message).toContain("Unknown aggregate function");
216+
});
217+
218+
test("auto-defaults orderby when group-by + limit provided", async () => {
219+
const { context } = createMockContext();
220+
const func = await addCommand.loader();
221+
await func.call(
222+
context,
223+
{
224+
json: false,
225+
display: "bar",
226+
query: ["count"],
227+
"group-by": ["browser.name"],
228+
limit: 5,
229+
},
230+
"123",
231+
"Top Browsers"
232+
);
233+
234+
const body = updateDashboardSpy.mock.calls[0]?.[2];
235+
const addedWidget = body.widgets.at(-1);
236+
expect(addedWidget.queries[0].orderby).toBe("-count()");
237+
});
238+
});

0 commit comments

Comments
 (0)