Skip to content

Commit c8e1611

Browse files
committed
test(log): add tests for log view command
Unit tests: - test/commands/log/view.test.ts: parsePositionalArgs, resolveFromProjectSearch E2E tests: - test/e2e/log.test.ts: auth, context resolution, JSON output, error handling Formatter tests: - test/lib/formatters/log.test.ts: formatLogDetails output sections Supporting changes: - Export resolveFromProjectSearch for testing - Add test/fixtures/log-detail.json fixture - Update test/mocks/routes.ts to handle single log queries
1 parent 82f56fd commit c8e1611

6 files changed

Lines changed: 515 additions & 4 deletions

File tree

src/commands/log/view.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ export function parsePositionalArgs(args: string[]): {
6060

6161
/**
6262
* Resolved target type for log commands.
63+
* @internal Exported for testing
6364
*/
64-
type ResolvedLogTarget = {
65+
export type ResolvedLogTarget = {
6566
org: string;
6667
project: string;
6768
detectedFrom?: string;
@@ -78,8 +79,10 @@ type ResolvedLogTarget = {
7879
* @returns Resolved target with org and project info
7980
* @throws {ContextError} If no project found
8081
* @throws {ValidationError} If project exists in multiple organizations
82+
*
83+
* @internal Exported for testing
8184
*/
82-
async function resolveFromProjectSearch(
85+
export async function resolveFromProjectSearch(
8386
projectSlug: string,
8487
logId: string
8588
): Promise<ResolvedLogTarget> {

test/commands/log/view.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* Log View Command Tests
3+
*
4+
* Tests for positional argument parsing and project resolution
5+
* in src/commands/log/view.ts
6+
*/
7+
8+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
9+
import {
10+
parsePositionalArgs,
11+
resolveFromProjectSearch,
12+
} from "../../../src/commands/log/view.js";
13+
import type { ProjectWithOrg } from "../../../src/lib/api-client.js";
14+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
15+
import * as apiClient from "../../../src/lib/api-client.js";
16+
import { ContextError, ValidationError } from "../../../src/lib/errors.js";
17+
18+
describe("parsePositionalArgs", () => {
19+
describe("single argument (log ID only)", () => {
20+
test("parses single arg as log ID", () => {
21+
const result = parsePositionalArgs(["abc123def456"]);
22+
expect(result.logId).toBe("abc123def456");
23+
expect(result.targetArg).toBeUndefined();
24+
});
25+
26+
test("parses 32-char hex log ID", () => {
27+
const result = parsePositionalArgs(["968c763c740cfda8b6728f27fb9e9b01"]);
28+
expect(result.logId).toBe("968c763c740cfda8b6728f27fb9e9b01");
29+
expect(result.targetArg).toBeUndefined();
30+
});
31+
32+
test("parses short log ID", () => {
33+
const result = parsePositionalArgs(["abc"]);
34+
expect(result.logId).toBe("abc");
35+
expect(result.targetArg).toBeUndefined();
36+
});
37+
});
38+
39+
describe("two arguments (target + log ID)", () => {
40+
test("parses org/project target and log ID", () => {
41+
const result = parsePositionalArgs(["my-org/frontend", "abc123def456"]);
42+
expect(result.targetArg).toBe("my-org/frontend");
43+
expect(result.logId).toBe("abc123def456");
44+
});
45+
46+
test("parses project-only target and log ID", () => {
47+
const result = parsePositionalArgs(["frontend", "abc123def456"]);
48+
expect(result.targetArg).toBe("frontend");
49+
expect(result.logId).toBe("abc123def456");
50+
});
51+
52+
test("parses org/ target (all projects) and log ID", () => {
53+
const result = parsePositionalArgs(["my-org/", "abc123def456"]);
54+
expect(result.targetArg).toBe("my-org/");
55+
expect(result.logId).toBe("abc123def456");
56+
});
57+
});
58+
59+
describe("error cases", () => {
60+
test("throws ContextError for empty args", () => {
61+
expect(() => parsePositionalArgs([])).toThrow(ContextError);
62+
});
63+
64+
test("throws ContextError with usage hint", () => {
65+
try {
66+
parsePositionalArgs([]);
67+
expect.unreachable("Should have thrown");
68+
} catch (error) {
69+
expect(error).toBeInstanceOf(ContextError);
70+
expect((error as ContextError).message).toContain("Log ID");
71+
}
72+
});
73+
});
74+
75+
describe("edge cases", () => {
76+
test("handles more than two args (ignores extras)", () => {
77+
const result = parsePositionalArgs([
78+
"my-org/frontend",
79+
"abc123",
80+
"extra-arg",
81+
]);
82+
expect(result.targetArg).toBe("my-org/frontend");
83+
expect(result.logId).toBe("abc123");
84+
});
85+
86+
test("handles empty string log ID in two-arg case", () => {
87+
const result = parsePositionalArgs(["my-org/frontend", ""]);
88+
expect(result.targetArg).toBe("my-org/frontend");
89+
expect(result.logId).toBe("");
90+
});
91+
});
92+
});
93+
94+
describe("resolveFromProjectSearch", () => {
95+
let findProjectsBySlugSpy: ReturnType<typeof spyOn>;
96+
97+
beforeEach(() => {
98+
findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug");
99+
});
100+
101+
afterEach(() => {
102+
findProjectsBySlugSpy.mockRestore();
103+
});
104+
105+
describe("no projects found", () => {
106+
test("throws ContextError when project not found", async () => {
107+
findProjectsBySlugSpy.mockResolvedValue([]);
108+
109+
await expect(
110+
resolveFromProjectSearch("my-project", "log-123")
111+
).rejects.toThrow(ContextError);
112+
});
113+
114+
test("includes project name in error message", async () => {
115+
findProjectsBySlugSpy.mockResolvedValue([]);
116+
117+
try {
118+
await resolveFromProjectSearch("frontend", "log-123");
119+
expect.unreachable("Should have thrown");
120+
} catch (error) {
121+
expect(error).toBeInstanceOf(ContextError);
122+
expect((error as ContextError).message).toContain('Project "frontend"');
123+
expect((error as ContextError).message).toContain(
124+
"Check that you have access"
125+
);
126+
}
127+
});
128+
});
129+
130+
describe("multiple projects found", () => {
131+
test("throws ValidationError when project exists in multiple orgs", async () => {
132+
findProjectsBySlugSpy.mockResolvedValue([
133+
{ slug: "frontend", orgSlug: "org-a", id: "1", name: "Frontend" },
134+
{ slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" },
135+
] as ProjectWithOrg[]);
136+
137+
await expect(
138+
resolveFromProjectSearch("frontend", "log-123")
139+
).rejects.toThrow(ValidationError);
140+
});
141+
142+
test("includes all orgs in error message", async () => {
143+
findProjectsBySlugSpy.mockResolvedValue([
144+
{ slug: "frontend", orgSlug: "acme-corp", id: "1", name: "Frontend" },
145+
{ slug: "frontend", orgSlug: "beta-inc", id: "2", name: "Frontend" },
146+
] as ProjectWithOrg[]);
147+
148+
try {
149+
await resolveFromProjectSearch("frontend", "log-456");
150+
expect.unreachable("Should have thrown");
151+
} catch (error) {
152+
expect(error).toBeInstanceOf(ValidationError);
153+
const message = (error as ValidationError).message;
154+
expect(message).toContain("exists in multiple organizations");
155+
expect(message).toContain("acme-corp/frontend");
156+
expect(message).toContain("beta-inc/frontend");
157+
expect(message).toContain("log-456"); // Log ID in example
158+
}
159+
});
160+
161+
test("includes usage example in error message", async () => {
162+
findProjectsBySlugSpy.mockResolvedValue([
163+
{ slug: "api", orgSlug: "org-1", id: "1", name: "API" },
164+
{ slug: "api", orgSlug: "org-2", id: "2", name: "API" },
165+
{ slug: "api", orgSlug: "org-3", id: "3", name: "API" },
166+
] as ProjectWithOrg[]);
167+
168+
try {
169+
await resolveFromProjectSearch("api", "abc123");
170+
expect.unreachable("Should have thrown");
171+
} catch (error) {
172+
expect(error).toBeInstanceOf(ValidationError);
173+
const message = (error as ValidationError).message;
174+
expect(message).toContain("Example: sentry log view api abc123");
175+
}
176+
});
177+
});
178+
179+
describe("single project found", () => {
180+
test("returns resolved target for single match", async () => {
181+
findProjectsBySlugSpy.mockResolvedValue([
182+
{ slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" },
183+
] as ProjectWithOrg[]);
184+
185+
const result = await resolveFromProjectSearch("backend", "log-xyz");
186+
187+
expect(result).toEqual({
188+
org: "my-company",
189+
project: "backend",
190+
});
191+
});
192+
193+
test("uses orgSlug from project result", async () => {
194+
findProjectsBySlugSpy.mockResolvedValue([
195+
{
196+
slug: "mobile-app",
197+
orgSlug: "acme-industries",
198+
id: "100",
199+
name: "Mobile App",
200+
},
201+
] as ProjectWithOrg[]);
202+
203+
const result = await resolveFromProjectSearch("mobile-app", "log-001");
204+
205+
expect(result.org).toBe("acme-industries");
206+
});
207+
208+
test("preserves project slug in result", async () => {
209+
findProjectsBySlugSpy.mockResolvedValue([
210+
{ slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" },
211+
] as ProjectWithOrg[]);
212+
213+
const result = await resolveFromProjectSearch("web-frontend", "log123");
214+
215+
expect(result.project).toBe("web-frontend");
216+
});
217+
});
218+
});

test/e2e/log.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { createE2EContext, type E2EContext } from "../fixture.js";
1717
import { cleanupTestDir, createTestConfigDir } from "../helpers.js";
1818
import {
1919
createSentryMockServer,
20+
TEST_LOG_ID,
2021
TEST_ORG,
2122
TEST_PROJECT,
2223
TEST_TOKEN,
@@ -141,3 +142,71 @@ describe("sentry log list", () => {
141142
expect(result.stdout).toMatch(/poll interval/i);
142143
});
143144
});
145+
146+
describe("sentry log view", () => {
147+
test("requires authentication", async () => {
148+
const result = await ctx.run([
149+
"log",
150+
"view",
151+
`${TEST_ORG}/${TEST_PROJECT}`,
152+
TEST_LOG_ID,
153+
]);
154+
155+
expect(result.exitCode).toBe(1);
156+
expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i);
157+
});
158+
159+
test("requires org and project without DSN", async () => {
160+
await ctx.setAuthToken(TEST_TOKEN);
161+
162+
const result = await ctx.run(["log", "view", TEST_LOG_ID]);
163+
164+
expect(result.exitCode).toBe(1);
165+
expect(result.stderr + result.stdout).toMatch(/organization|project/i);
166+
});
167+
168+
test("fetches log with valid auth", async () => {
169+
await ctx.setAuthToken(TEST_TOKEN);
170+
171+
const result = await ctx.run([
172+
"log",
173+
"view",
174+
`${TEST_ORG}/${TEST_PROJECT}`,
175+
TEST_LOG_ID,
176+
]);
177+
178+
expect(result.exitCode).toBe(0);
179+
expect(result.stdout).toContain("Log");
180+
expect(result.stdout).toContain(TEST_LOG_ID);
181+
});
182+
183+
test("supports --json output", async () => {
184+
await ctx.setAuthToken(TEST_TOKEN);
185+
186+
const result = await ctx.run([
187+
"log",
188+
"view",
189+
`${TEST_ORG}/${TEST_PROJECT}`,
190+
TEST_LOG_ID,
191+
"--json",
192+
]);
193+
194+
expect(result.exitCode).toBe(0);
195+
const data = JSON.parse(result.stdout);
196+
expect(data["sentry.item_id"]).toBe(TEST_LOG_ID);
197+
});
198+
199+
test("handles non-existent log", async () => {
200+
await ctx.setAuthToken(TEST_TOKEN);
201+
202+
const result = await ctx.run([
203+
"log",
204+
"view",
205+
`${TEST_ORG}/${TEST_PROJECT}`,
206+
"nonexistent-log-id-12345",
207+
]);
208+
209+
expect(result.exitCode).toBe(1);
210+
expect(result.stderr + result.stdout).toMatch(/not found|no log/i);
211+
});
212+
});

test/fixtures/log-detail.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"data": [
3+
{
4+
"sentry.item_id": "log-detail-001",
5+
"timestamp": "2025-01-30T14:32:15+00:00",
6+
"timestamp_precise": 1770060419044800300,
7+
"message": "Detailed test log message",
8+
"severity": "info",
9+
"trace": "abc123def456abc123def456abc12345",
10+
"project": "test-project",
11+
"environment": "production",
12+
"release": "1.0.0",
13+
"sdk.name": "sentry.javascript.node",
14+
"sdk.version": "8.0.0",
15+
"span_id": "span123abc",
16+
"code.function": "handleRequest",
17+
"code.file.path": "src/handlers/api.ts",
18+
"code.line.number": "42",
19+
"sentry.otel.kind": null,
20+
"sentry.otel.status_code": null,
21+
"sentry.otel.instrumentation_scope.name": null
22+
}
23+
],
24+
"meta": {
25+
"fields": {
26+
"sentry.item_id": "string",
27+
"timestamp": "string",
28+
"timestamp_precise": "number",
29+
"message": "string",
30+
"severity": "string",
31+
"trace": "string",
32+
"project": "string",
33+
"environment": "string",
34+
"release": "string",
35+
"sdk.name": "string",
36+
"sdk.version": "string",
37+
"span_id": "string",
38+
"code.function": "string",
39+
"code.file.path": "string",
40+
"code.line.number": "string"
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)