import { beforeEach, describe, expect, test, vi } from "vitest"; const patchLoadedBuild = vi.hoisted(() => vi.fn()); const authorizePatchEntries = vi.hoisted(() => vi.fn()); const createContentModeCapabilities = vi.hoisted(() => vi.fn(() => ({ capabilities: true })) ); vi.mock("@webstudio-is/project/index.server", () => ({ patchLoadedBuild, })); vi.mock("./patch-auth.server", () => ({ assertProjectPermit: vi.fn(), authorizePatchEntries, createContentModeCapabilities, createWriterContext: vi.fn(), })); import { applyPatchRequest, loadAuthorizedPatchState, } from "./patch-service.server"; import type { AppContext } from "@webstudio-is/trpc-interface/index.server"; import type { NormalizedPatchRequest } from "./patch-normalize.server"; const applyPatchRequestForTest = ( context: AppContext, patch: NormalizedPatchRequest ) => applyPatchRequest(context, patch); const configuredResources = JSON.stringify([ { id: "posts", name: "Posts", control: "system", method: "post", url: JSON.stringify("/$resources/assets"), headers: [], body: '{ query: { where: { all: [] }, sort: [], limit: 20, offset: 0, content: { mode: "none" } } }', }, ]); const buildRow = { projectId: "project-1", version: 3, lastTransactionId: null, instances: JSON.stringify([]), props: JSON.stringify([]), styleSources: JSON.stringify([]), styleSourceSelections: JSON.stringify([]), styles: JSON.stringify([]), breakpoints: JSON.stringify([]), resources: configuredResources, }; const createContext = () => { const selectedColumns: string[] = []; const context = { postgrest: { client: { from: () => ({ select: (columns: string) => ({ selectedColumns, eq: () => { selectedColumns.push(columns); const result = Promise.resolve({ data: [buildRow], error: undefined, }); return Object.assign(result, { single: async () => ({ data: buildRow, error: undefined, }), }); }, }), }), }, }, }; return Object.assign(context, { selectedColumns, }) as unknown as AppContext & { selectedColumns: string[] }; }; const transaction = (id: string, namespace = "props") => ({ id, payload: [{ namespace, patches: [] }], }); const patch: NormalizedPatchRequest = { buildId: "build-1", projectId: "project-1", clientVersion: 3, entries: [ { seq: 1, transaction: transaction("tx-1") as never, writer: { type: "token", authToken: "token-1" }, }, { seq: 2, transaction: transaction("tx-2") as never, writer: { type: "token", authToken: "token-2" }, }, ], }; describe("applyPatchRequest", () => { beforeEach(() => { patchLoadedBuild.mockReset(); authorizePatchEntries.mockReset(); createContentModeCapabilities.mockClear(); }); test("applies a combined resource and asset patch without derived work", async () => { const combinedEntry = { ...patch.entries[0], transaction: { id: "tx-combined", payload: [ { namespace: "resources", patches: [] }, { namespace: "assets", patches: [ { op: "add", path: ["asset-1"], value: { id: "asset-1", name: "post.md" }, }, ], }, ], } as never, }; authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: combinedEntry, context: { writer: 1 } }], rejected: [], }); const nextResources = JSON.stringify([ { id: "posts", name: "Posts", control: "system", method: "post", url: JSON.stringify("/$resources/assets"), headers: [], body: '{ query: { where: { all: [] }, sort: [], limit: 10, offset: 0, content: { mode: "none" } } }', }, ]); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4, resources: nextResources }, })); await applyPatchRequestForTest(createContext(), { ...patch, entries: [combinedEntry], }); }); test("applies asset-folder patches without derived work", async () => { const folderEntry = { ...patch.entries[0], transaction: { id: "tx-folder", payload: [ { namespace: "assetFolders", patches: [ { op: "replace", path: ["folder-1", "name"], value: "News" }, ], }, ], } as never, }; authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: folderEntry, context: { writer: 1 } }], rejected: [], }); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); await expect( applyPatchRequestForTest(createContext(), { ...patch, entries: [folderEntry], }) ).resolves.toMatchObject({ status: "ok" }); }); test("applies added assets without derived work", async () => { const assetEntry = { ...patch.entries[0], transaction: { id: "tx-asset-add", payload: [ { namespace: "assets", patches: [ { op: "add", path: ["asset-1"], value: { id: "asset-1", name: "post.md" }, }, ], }, ], } as never, }; authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: assetEntry, context: { writer: 1 } }], rejected: [], }); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); await expect( applyPatchRequestForTest(createContext(), { ...patch, entries: [assetEntry], }) ).resolves.toMatchObject({ status: "ok" }); }); test("applies a swapped asset revision without derived work", async () => { const assetEntry = { ...patch.entries[0], transaction: { id: "tx-asset-revision", payload: [ { namespace: "assets", patches: [ { op: "replace", path: ["asset-1", "name"], value: "post-revision.md", }, ], }, ], } as never, }; authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: assetEntry, context: { writer: 1 } }], rejected: [], }); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); await applyPatchRequestForTest(createContext(), { ...patch, entries: [assetEntry], }); }); test("applies standard asset metadata changes without derived work", async () => { const assetEntry = { ...patch.entries[0], transaction: { id: "tx-asset-filename", payload: [ { namespace: "assets", patches: [ { op: "replace", path: ["asset-1", "filename"], value: "Public post title.md", }, ], }, ], } as never, }; authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: assetEntry, context: { writer: 1 } }], rejected: [], }); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); await applyPatchRequestForTest(createContext(), { ...patch, entries: [assetEntry], }); }); test("applies asset descriptions without derived work", async () => { const assetEntry = { ...patch.entries[0], transaction: { id: "tx-asset-description", payload: [ { namespace: "assets", patches: [ { op: "replace", path: ["asset-1", "description"], value: "Decorative description", }, ], }, ], } as never, }; authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: assetEntry, context: { writer: 1 } }], rejected: [], }); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); await applyPatchRequestForTest(createContext(), { ...patch, entries: [assetEntry], }); }); test("returns per-entry partial results while applying authorized entries", async () => { authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: patch.entries[1], context: { writer: 2 } }], rejected: [ { entry: patch.entries[0], errors: "You don't have permission to edit this project.", }, ], }); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); const context = createContext(); const result = await applyPatchRequestForTest(context, patch); expect(createContentModeCapabilities).not.toHaveBeenCalled(); expect(context.selectedColumns).toEqual([ "projectId, version", "projectId, version, lastTransactionId, props", ]); expect(authorizePatchEntries).toHaveBeenCalledWith( expect.anything(), patch, expect.any(Function) ); expect(patchLoadedBuild).toHaveBeenCalledWith( expect.objectContaining({ build: expect.objectContaining({ version: 3 }), transactions: [patch.entries[1].transaction], }), { writer: 2 } ); expect(result).toEqual({ status: "partial", version: 4, entries: [ { seq: 1, transactionId: "tx-1", status: "rejected", errors: "You don't have permission to edit this project.", }, { seq: 2, transactionId: "tx-2", status: "accepted" }, ], }); }); test("loads content-mode capability columns only when authorization requests them", async () => { authorizePatchEntries.mockImplementation( async (_context, _patch, getInitialContentModeCapabilities) => { await getInitialContentModeCapabilities(); return { authorized: [{ entry: patch.entries[0], context: { writer: 1 } }], rejected: [], }; } ); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); const context = createContext(); const result = await applyPatchRequestForTest(context, patch); expect(createContentModeCapabilities).toHaveBeenCalledWith({ breakpoints: JSON.stringify([]), instances: JSON.stringify([]), props: JSON.stringify([]), styleSourceSelections: JSON.stringify([]), styleSources: JSON.stringify([]), styles: JSON.stringify([]), }); expect(context.selectedColumns).toEqual([ "projectId, version", "instances, props, styleSources, styleSourceSelections, styles, breakpoints", "projectId, version, lastTransactionId, props", ]); expect(result).toEqual({ status: "ok", version: 4, entries: [{ seq: 1, transactionId: "tx-1", status: "accepted" }], }); }); test("does not load patch columns or persist when every entry is rejected", async () => { authorizePatchEntries.mockResolvedValue({ authorized: [], rejected: [ { entry: patch.entries[0], errors: "You don't have permission to edit this project.", }, ], }); const context = createContext(); const result = await applyPatchRequestForTest(context, patch); expect(context.selectedColumns).toEqual(["projectId, version"]); expect(patchLoadedBuild).not.toHaveBeenCalled(); expect(result).toEqual({ status: "partial", version: 3, entries: [ { seq: 1, transactionId: "tx-1", status: "rejected", errors: "You don't have permission to edit this project.", }, ], }); }); test("does not load resources for an asset-only patch", async () => { const assetsPatch: NormalizedPatchRequest = { ...patch, entries: [ { seq: 1, transaction: transaction("tx-assets", "assets") as never, writer: { type: "token", authToken: "token-1" }, }, ], }; authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: assetsPatch.entries[0], context: { writer: 1 } }], rejected: [], }); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); const context = createContext(); const result = await applyPatchRequestForTest(context, assetsPatch); expect(context.selectedColumns).toEqual([ "projectId, version", "projectId, version, lastTransactionId", ]); expect(patchLoadedBuild).toHaveBeenCalledWith( expect.objectContaining({ build: expect.objectContaining({ projectId: "project-1", version: 3, lastTransactionId: null, }), transactions: [assetsPatch.entries[0].transaction], }), { writer: 1 } ); expect(result).toEqual({ status: "ok", version: 4, entries: [{ seq: 1, transactionId: "tx-assets", status: "accepted" }], }); }); test("falls back to per-entry apply when a mixed authorized batch fails", async () => { const context = { writer: 1 }; authorizePatchEntries.mockResolvedValue({ authorized: [ { entry: patch.entries[0], context }, { entry: patch.entries[1], context }, ], rejected: [], }); patchLoadedBuild .mockResolvedValueOnce({ status: "error", errors: "batch failed" }) .mockImplementationOnce(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })) .mockResolvedValueOnce({ status: "error", errors: "entry failed" }); const result = await applyPatchRequestForTest(createContext(), patch); expect(result).toEqual({ status: "partial", version: 4, entries: [ { seq: 1, transactionId: "tx-1", status: "accepted" }, { seq: 2, transactionId: "tx-2", status: "failed", errors: "entry failed", }, ], }); }); test("applies mixed writer entries with their own context without batching", async () => { authorizePatchEntries.mockResolvedValue({ authorized: [ { entry: patch.entries[0], context: { writer: 1 } }, { entry: patch.entries[1], context: { writer: 2 } }, ], rejected: [], }); patchLoadedBuild .mockImplementationOnce(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })) .mockImplementationOnce(async ({ build }) => ({ status: "ok", version: 5, build: { ...build, version: 5 }, })); const result = await applyPatchRequestForTest(createContext(), patch); expect(patchLoadedBuild).toHaveBeenCalledTimes(2); expect(patchLoadedBuild).toHaveBeenNthCalledWith( 1, expect.objectContaining({ build: expect.objectContaining({ version: 3 }), clientVersion: 3, transactions: [patch.entries[0].transaction], }), { writer: 1 } ); expect(patchLoadedBuild).toHaveBeenNthCalledWith( 2, expect.objectContaining({ build: expect.objectContaining({ version: 4 }), clientVersion: 4, transactions: [patch.entries[1].transaction], }), { writer: 2 } ); expect(result).toEqual({ status: "ok", version: 5, entries: [ { seq: 1, transactionId: "tx-1", status: "accepted" }, { seq: 2, transactionId: "tx-2", status: "accepted" }, ], }); }); test("preserves entry order when transaction ids are duplicated", async () => { const duplicatePatch: NormalizedPatchRequest = { ...patch, entries: [ { seq: 1, transaction: transaction("tx-duplicate") as never, writer: { type: "token", authToken: "token-1" }, }, { seq: 2, transaction: transaction("tx-duplicate") as never, writer: { type: "token", authToken: "token-2" }, }, ], }; authorizePatchEntries.mockResolvedValue({ authorized: [ { entry: duplicatePatch.entries[1], context: { writer: 2 } }, ], rejected: [ { entry: duplicatePatch.entries[0], errors: "You don't have permission to edit this project.", }, ], }); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })); const result = await applyPatchRequestForTest( createContext(), duplicatePatch ); expect(result).toEqual({ status: "partial", version: 4, entries: [ { seq: 1, transactionId: "tx-duplicate", status: "rejected", errors: "You don't have permission to edit this project.", }, { seq: 2, transactionId: "tx-duplicate", status: "accepted" }, ], }); }); test("does not convert thrown per-entry persistence errors into terminal entry failures", async () => { const context = { writer: 1 }; authorizePatchEntries.mockResolvedValue({ authorized: [ { entry: patch.entries[0], context }, { entry: patch.entries[1], context }, ], rejected: [], }); patchLoadedBuild .mockResolvedValueOnce({ status: "error", errors: "batch failed" }) .mockImplementationOnce(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4 }, })) .mockRejectedValueOnce(new Error("PostgREST unavailable")); await expect( applyPatchRequestForTest(createContext(), patch) ).rejects.toThrow("PostgREST unavailable"); }); }); describe("loadAuthorizedPatchState", () => { beforeEach(() => { createContentModeCapabilities.mockClear(); }); test("does not load content-mode capabilities for initial patch state", async () => { await expect( loadAuthorizedPatchState({ authToken: "token", buildId: "build-1", context: createContext(), }) ).resolves.toEqual({ projectId: "project-1", version: 3, }); expect(createContentModeCapabilities).not.toHaveBeenCalled(); }); });