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
17 changes: 16 additions & 1 deletion src/app/api/blueprints/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
109 changes: 109 additions & 0 deletions tests/api/blueprints/update-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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();
});
});
Loading