Skip to content

Commit e25eb66

Browse files
committed
test: add coverage for org-scoped member project creation fallback
Adds 10 new tests across three files to bring coverage from ~57% to target 80% for the new fallback paths: create-sentry-project.test.ts: - fallback to createProjectWithAutoTeam on 403 from team-based creation - suppressFallback:true (isExplicitTeam) prevents fallback for --team flag - policy 403 (disabled this feature) skips fallback without round-trip - 409 from fallback surfaces friendly 'already exists' error preflight.test.ts: - isExplicitTeam:true when --team flag provided - isExplicitTeam:false when no flag (auto-selected team) - resolveTeam 403 swallowed, context.team:undefined (enables fallback downstream) api-client.coverage.test.ts: - createProjectWithAutoTeam happy path (POST url, DSN, team_slug in result) - 403 from disabled org policy propagated - DSN:null when key fetch returns empty list
1 parent cf65ba7 commit e25eb66

3 files changed

Lines changed: 224 additions & 0 deletions

File tree

test/lib/api-client.coverage.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
apiRequest,
1515
apiRequestToRegion,
1616
createProject,
17+
createProjectWithAutoTeam,
1718
createTeam,
1819
getCurrentUser,
1920
getDetailedTrace,
@@ -35,6 +36,7 @@ import {
3536
listTeamsPaginated,
3637
listTraceLogs,
3738
listTransactions,
39+
MEMBER_PROJECT_CREATION_DISABLED_DETAIL,
3840
rawApiRequest,
3941
tryGetPrimaryDsn,
4042
updateIssueStatus,
@@ -680,6 +682,84 @@ describe("projects.ts", () => {
680682
});
681683
});
682684

685+
describe("createProjectWithAutoTeam", () => {
686+
const autoTeamProject = {
687+
id: "99",
688+
slug: "auto-proj",
689+
name: "Auto Project",
690+
platform: "node",
691+
dateCreated: "2026-01-01T00:00:00Z",
692+
team_slug: "team-testuser",
693+
};
694+
const dsnKeys = [
695+
{
696+
id: "k1",
697+
isActive: true,
698+
dsn: { public: "https://key@o1.ingest.sentry.io/99", secret: "" },
699+
},
700+
];
701+
702+
test("POSTs to /organizations/{org}/projects/ and returns project with DSN and team_slug", async () => {
703+
globalThis.fetch = mockFetch(async (input, init) => {
704+
const req = new Request(input!, init);
705+
if (req.url.includes("/projects/") && req.method === "POST") {
706+
return new Response(JSON.stringify(autoTeamProject), {
707+
status: 201,
708+
headers: { "Content-Type": "application/json" },
709+
});
710+
}
711+
// DSN key fetch
712+
return new Response(JSON.stringify(dsnKeys), {
713+
status: 200,
714+
headers: { "Content-Type": "application/json" },
715+
});
716+
});
717+
718+
const result = await createProjectWithAutoTeam("test-org", {
719+
name: "Auto Project",
720+
});
721+
expect(result.project.slug).toBe("auto-proj");
722+
expect(result.team_slug).toBe("team-testuser");
723+
expect(result.dsn).toContain("key");
724+
expect(result.url).toContain("auto-proj");
725+
});
726+
727+
test("propagates 403 when org has disabled member project creation", async () => {
728+
globalThis.fetch = mockFetch(
729+
async () =>
730+
new Response(
731+
JSON.stringify({ detail: MEMBER_PROJECT_CREATION_DISABLED_DETAIL }),
732+
{ status: 403, headers: { "Content-Type": "application/json" } }
733+
)
734+
);
735+
736+
await expect(
737+
createProjectWithAutoTeam("test-org", { name: "Blocked" })
738+
).rejects.toMatchObject({ status: 403 });
739+
});
740+
741+
test("returns dsn:null when DSN fetch fails", async () => {
742+
globalThis.fetch = mockFetch(async (input, init) => {
743+
const req = new Request(input!, init);
744+
if (req.method === "POST") {
745+
return new Response(JSON.stringify(autoTeamProject), {
746+
status: 201,
747+
headers: { "Content-Type": "application/json" },
748+
});
749+
}
750+
return new Response("[]", {
751+
status: 200,
752+
headers: { "Content-Type": "application/json" },
753+
});
754+
});
755+
756+
const result = await createProjectWithAutoTeam("test-org", {
757+
name: "Auto Project",
758+
});
759+
expect(result.dsn).toBeNull();
760+
});
761+
});
762+
683763
describe("getProjectKeys", () => {
684764
test("returns project client keys", async () => {
685765
const keys = [

test/lib/init/preflight.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,4 +437,40 @@ describe("resolveInitContext", () => {
437437

438438
expect(context?.authToken).toBe("sntrys_test");
439439
});
440+
441+
test("sets isExplicitTeam:true when --team flag is provided", async () => {
442+
resolveOrCreateTeamSpy.mockResolvedValue({
443+
slug: "backend",
444+
source: "explicit",
445+
} as any);
446+
447+
const { ui } = createMockUI();
448+
const context = await resolveInitContext(
449+
makeOptions({ team: "backend" }),
450+
ui
451+
);
452+
453+
expect(context?.isExplicitTeam).toBe(true);
454+
expect(context?.team).toBe("backend");
455+
});
456+
457+
test("sets isExplicitTeam:false when no --team flag is provided", async () => {
458+
const { ui } = createMockUI();
459+
const context = await resolveInitContext(makeOptions(), ui);
460+
461+
expect(context?.isExplicitTeam).toBe(false);
462+
});
463+
464+
test("swallows 403 from listTeams and resolves context with team:undefined", async () => {
465+
resolveOrCreateTeamSpy.mockRejectedValueOnce(
466+
new ApiError("Forbidden", 403, "No team:read access")
467+
);
468+
469+
const { ui } = createMockUI();
470+
const context = await resolveInitContext(makeOptions(), ui);
471+
472+
// 403 is swallowed so the wizard can proceed to the org-scoped fallback
473+
expect(context).not.toBeNull();
474+
expect(context?.team).toBeUndefined();
475+
});
440476
});

test/lib/init/tools/create-sentry-project.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,114 @@ describe("createSentryProject", () => {
297297
expect(createSentryProjectTool.describe(makePayload())).toContain("my-app");
298298
});
299299

300+
// ── org-scoped fallback (createProjectWithAutoTeam) ─────────────────────
301+
302+
test("falls back to org-scoped endpoint on 403 from team-based creation", async () => {
303+
const createProjectWithAutoTeamSpy = vi
304+
.spyOn(apiClient, "createProjectWithAutoTeam")
305+
.mockResolvedValue({
306+
project: {
307+
id: "42",
308+
slug: "my-app",
309+
name: "my-app",
310+
platform: "javascript-react",
311+
dateCreated: "2026-04-16T00:00:00Z",
312+
} as any,
313+
dsn: "https://abc@o1.ingest.sentry.io/42",
314+
url: "https://sentry.io/settings/acme/projects/my-app/",
315+
team_slug: "team-testuser",
316+
});
317+
getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404));
318+
createProjectWithDsnSpy.mockRejectedValueOnce(
319+
new ApiError("Forbidden", 403, "No project:write access")
320+
);
321+
322+
const result = await createSentryProject(makePayload(), {
323+
dryRun: false,
324+
org: "acme",
325+
team: undefined,
326+
project: undefined,
327+
});
328+
329+
expect(result.ok).toBe(true);
330+
expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme", {
331+
name: "my-app",
332+
platform: "javascript-react",
333+
});
334+
createProjectWithAutoTeamSpy.mockRestore();
335+
});
336+
337+
test("suppresses fallback when team was set via --team (isExplicitTeam)", async () => {
338+
const createProjectWithAutoTeamSpy = vi
339+
.spyOn(apiClient, "createProjectWithAutoTeam")
340+
.mockResolvedValue({} as any);
341+
getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404));
342+
createProjectWithDsnSpy.mockRejectedValueOnce(
343+
new ApiError("Forbidden", 403, "No project:write access")
344+
);
345+
346+
const result = await createSentryProject(makePayload(), {
347+
dryRun: false,
348+
org: "acme",
349+
team: "backend",
350+
isExplicitTeam: true,
351+
project: undefined,
352+
});
353+
354+
expect(result.ok).toBe(false);
355+
expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled();
356+
createProjectWithAutoTeamSpy.mockRestore();
357+
});
358+
359+
test("does not fall back on policy 403 (disabled this feature) — avoids wasted round-trip", async () => {
360+
const createProjectWithAutoTeamSpy = vi
361+
.spyOn(apiClient, "createProjectWithAutoTeam")
362+
.mockResolvedValue({} as any);
363+
getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404));
364+
createProjectWithDsnSpy.mockRejectedValueOnce(
365+
new ApiError(
366+
"Forbidden",
367+
403,
368+
"Your organization has disabled this feature for members."
369+
)
370+
);
371+
372+
const result = await createSentryProject(makePayload(), {
373+
dryRun: false,
374+
org: "acme",
375+
team: undefined,
376+
project: undefined,
377+
});
378+
379+
expect(result.ok).toBe(false);
380+
expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled();
381+
expect(result.error).toContain("disabled for members");
382+
createProjectWithAutoTeamSpy.mockRestore();
383+
});
384+
385+
test("surfaces friendly 409 error when fallback project already exists", async () => {
386+
const createProjectWithAutoTeamSpy = vi
387+
.spyOn(apiClient, "createProjectWithAutoTeam")
388+
.mockRejectedValue(new ApiError("Conflict", 409, "Slug already in use"));
389+
getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404));
390+
createProjectWithDsnSpy.mockRejectedValueOnce(
391+
new ApiError("Forbidden", 403, "No project:write access")
392+
);
393+
394+
const result = await createSentryProject(makePayload(), {
395+
dryRun: false,
396+
org: "acme",
397+
team: undefined,
398+
project: undefined,
399+
});
400+
401+
expect(result.ok).toBe(false);
402+
expect(result.error).toContain("already exists");
403+
createProjectWithAutoTeamSpy.mockRestore();
404+
});
405+
406+
// ── dry-run ──────────────────────────────────────────────────────────────
407+
300408
test("uses the final project slug for deferred team resolution in dry-run mode", async () => {
301409
getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404));
302410

0 commit comments

Comments
 (0)