Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/commands/event/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,11 @@ export function parsePositionalArgs(args: string[]): {
return { eventId: second, targetArg: first };
}

/** Resolved target type for internal use */
type ResolvedEventTarget = {
/**
* Resolved target type for event commands.
* @internal Exported for testing
*/
export type ResolvedEventTarget = {
org: string;
project: string;
orgDisplay: string;
Expand All @@ -104,8 +107,19 @@ type ResolvedEventTarget = {

/**
* Resolve target from a project search result.
*
* Searches for a project by slug across all accessible organizations.
* Throws if no project found or if multiple projects found in different orgs.
*
* @param projectSlug - Project slug to search for
* @param eventId - Event ID (used in error messages)
* @returns Resolved target with org and project info
* @throws {ContextError} If no project found
* @throws {ValidationError} If project exists in multiple organizations
*
* @internal Exported for testing
*/
async function resolveFromProjectSearch(
export async function resolveFromProjectSearch(
projectSlug: string,
eventId: string
): Promise<ResolvedEventTarget> {
Expand Down
147 changes: 143 additions & 4 deletions test/commands/event/view.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
/**
* Event View Command Tests
*
* Tests for positional argument parsing in src/commands/event/view.ts
* Tests for positional argument parsing and project resolution
* in src/commands/event/view.ts
*/

import { describe, expect, test } from "bun:test";
import { parsePositionalArgs } from "../../../src/commands/event/view.js";
import { ContextError } from "../../../src/lib/errors.js";
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import {
parsePositionalArgs,
resolveFromProjectSearch,
} from "../../../src/commands/event/view.js";
import type { ProjectWithOrg } from "../../../src/lib/api-client.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as apiClient from "../../../src/lib/api-client.js";
import { ContextError, ValidationError } from "../../../src/lib/errors.js";

describe("parsePositionalArgs", () => {
describe("single argument (event ID only)", () => {
Expand Down Expand Up @@ -85,3 +92,135 @@ describe("parsePositionalArgs", () => {
});
});
});

describe("resolveFromProjectSearch", () => {
let findProjectsBySlugSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug");
});

afterEach(() => {
findProjectsBySlugSpy.mockRestore();
});

describe("no projects found", () => {
test("throws ContextError when project not found", async () => {
findProjectsBySlugSpy.mockResolvedValue([]);

await expect(
resolveFromProjectSearch("my-project", "event-123")
).rejects.toThrow(ContextError);
});

test("includes project name in error message", async () => {
findProjectsBySlugSpy.mockResolvedValue([]);

try {
await resolveFromProjectSearch("frontend", "event-123");
expect.unreachable("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(ContextError);
expect((error as ContextError).message).toContain('Project "frontend"');
expect((error as ContextError).message).toContain(
"Check that you have access"
);
}
});
});

describe("multiple projects found", () => {
test("throws ValidationError when project exists in multiple orgs", async () => {
findProjectsBySlugSpy.mockResolvedValue([
{ slug: "frontend", orgSlug: "org-a", id: "1", name: "Frontend" },
{ slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" },
] as ProjectWithOrg[]);

await expect(
resolveFromProjectSearch("frontend", "event-123")
).rejects.toThrow(ValidationError);
});

test("includes all orgs in error message", async () => {
findProjectsBySlugSpy.mockResolvedValue([
{ slug: "frontend", orgSlug: "acme-corp", id: "1", name: "Frontend" },
{ slug: "frontend", orgSlug: "beta-inc", id: "2", name: "Frontend" },
] as ProjectWithOrg[]);

try {
await resolveFromProjectSearch("frontend", "event-456");
expect.unreachable("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(ValidationError);
const message = (error as ValidationError).message;
expect(message).toContain("exists in multiple organizations");
expect(message).toContain("acme-corp/frontend");
expect(message).toContain("beta-inc/frontend");
expect(message).toContain("event-456"); // Event ID in example
}
});

test("includes usage example in error message", async () => {
findProjectsBySlugSpy.mockResolvedValue([
{ slug: "api", orgSlug: "org-1", id: "1", name: "API" },
{ slug: "api", orgSlug: "org-2", id: "2", name: "API" },
{ slug: "api", orgSlug: "org-3", id: "3", name: "API" },
] as ProjectWithOrg[]);

try {
await resolveFromProjectSearch("api", "abc123");
expect.unreachable("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(ValidationError);
const message = (error as ValidationError).message;
expect(message).toContain(
"Example: sentry event view <org>/api abc123"
);
}
});
});

describe("single project found", () => {
test("returns resolved target for single match", async () => {
findProjectsBySlugSpy.mockResolvedValue([
{ slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" },
] as ProjectWithOrg[]);

const result = await resolveFromProjectSearch("backend", "event-xyz");

expect(result).toEqual({
org: "my-company",
project: "backend",
orgDisplay: "my-company",
projectDisplay: "backend",
});
});

test("uses orgSlug from project result", async () => {
findProjectsBySlugSpy.mockResolvedValue([
{
slug: "mobile-app",
orgSlug: "acme-industries",
id: "100",
name: "Mobile App",
},
] as ProjectWithOrg[]);

const result = await resolveFromProjectSearch("mobile-app", "evt-001");

expect(result.org).toBe("acme-industries");
expect(result.orgDisplay).toBe("acme-industries");
});

test("preserves project slug in result", async () => {
findProjectsBySlugSpy.mockResolvedValue([
{ slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" },
] as ProjectWithOrg[]);

const result = await resolveFromProjectSearch("web-frontend", "e123");

expect(result.project).toBe("web-frontend");
expect(result.projectDisplay).toBe("web-frontend");
});
});
});
Loading