|
| 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 | +}); |
0 commit comments