diff --git a/src/app/api/blueprints/[id]/route.ts b/src/app/api/blueprints/[id]/route.ts index ca64a435..881a3288 100644 --- a/src/app/api/blueprints/[id]/route.ts +++ b/src/app/api/blueprints/[id]/route.ts @@ -129,7 +129,13 @@ export async function PUT( // Check if user owns this blueprint const existingBlueprint = await prismaUsers.userTemplate.findUnique({ where: { id: realId }, - select: { userId: true, currentVersion: true, content: true, isPublic: true }, + select: { + userId: true, + currentVersion: true, + content: true, + isPublic: true, + teamId: true, + }, }); if (!existingBlueprint) { @@ -218,6 +224,15 @@ export async function PUT( if (isPublic !== undefined) { updateData.isPublic = Boolean(isPublic); + // Write visibility together with the public flag so the two always + // match, even when two saves overlap. Publishing keeps teamId, and this + // route never changes it, so an unpublished blueprint with a team goes + // back to TEAM and one without goes to PRIVATE. + updateData.visibility = updateData.isPublic + ? "PUBLIC" + : existingBlueprint.teamId + ? "TEAM" + : "PRIVATE"; } if (showcaseUrl !== undefined) { diff --git a/tests/api/blueprints/update-visibility.test.ts b/tests/api/blueprints/update-visibility.test.ts new file mode 100644 index 00000000..170e851e --- /dev/null +++ b/tests/api/blueprints/update-visibility.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFindUnique = vi.fn(); +const mockUpdate = vi.fn(); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn().mockResolvedValue({ user: { id: "user_1" } }), +})); + +vi.mock("@/lib/auth", () => ({ authOptions: {} })); + +vi.mock("@/lib/db-users", () => ({ + prismaUsers: { + userTemplate: { findUnique: mockFindUnique, update: mockUpdate }, + userTemplateVersion: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +type Stored = { visibility: "PRIVATE" | "TEAM" | "PUBLIC"; isPublic: boolean; teamId: string | null }; + +function existing(row: Stored) { + mockFindUnique.mockResolvedValue({ + userId: "user_1", + currentVersion: 1, + content: "# AGENTS.md\n\nUse pnpm and keep functions small.", + ...row, + }); +} + +async function update(body: Record) { + const { PUT } = await import("@/app/api/blueprints/[id]/route"); + return PUT( + new Request("https://lynxprompt.com/api/blueprints/bp_tpl_1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: "bp_tpl_1" }) } + ); +} + +function written() { + expect(mockUpdate).toHaveBeenCalledTimes(1); + return mockUpdate.mock.calls[0][0].data; +} + +describe("PUT /api/blueprints/[id] visibility", () => { + beforeEach(() => { + mockFindUnique.mockReset(); + mockUpdate.mockReset(); + mockUpdate.mockImplementation(async ({ data }) => ({ id: "tpl_1", ...data })); + }); + + it("makes a private blueprint PUBLIC when the edit page publishes it", async () => { + existing({ visibility: "PRIVATE", isPublic: false, teamId: null }); + const response = await update({ isPublic: true, sensitiveDataAcknowledged: true }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBe(true); + expect(written().visibility).toBe("PUBLIC"); + }); + + it("makes a public blueprint PRIVATE when the edit page unpublishes it", async () => { + existing({ visibility: "PUBLIC", isPublic: true, teamId: null }); + const response = await update({ isPublic: false }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBe(false); + expect(written().visibility).toBe("PRIVATE"); + }); + + it("returns an unpublished team blueprint to its team", async () => { + existing({ visibility: "PUBLIC", isPublic: true, teamId: "team_1" }); + const response = await update({ isPublic: false }); + + expect(response.status).toBe(200); + expect(written().visibility).toBe("TEAM"); + expect(written().teamId).toBeUndefined(); + }); + + it("keeps a team blueprint TEAM when the edit page saves it unchecked", async () => { + existing({ visibility: "TEAM", isPublic: false, teamId: "team_1" }); + const response = await update({ name: "Renamed config", isPublic: false }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBe(false); + expect(written().visibility).toBe("TEAM"); + }); + + // Two overlapping saves: this one read the row as PRIVATE before another + // request published it. Writing only isPublic would leave visibility PUBLIC. + it("writes visibility with every unpublish, whatever the row said when read", async () => { + existing({ visibility: "PRIVATE", isPublic: false, teamId: null }); + const response = await update({ isPublic: false }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBe(false); + expect(written().visibility).toBe("PRIVATE"); + }); + + it("leaves visibility alone when isPublic is not sent", async () => { + existing({ visibility: "PUBLIC", isPublic: true, teamId: null }); + const response = await update({ name: "Renamed config" }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBeUndefined(); + expect(written().visibility).toBeUndefined(); + }); +});