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 { synchronizeAssetResourcesAfterBuildPatch } from "../../synchronize-asset-resource-patch.server"; import type { AppContext } from "@webstudio-is/trpc-interface/index.server"; import type { NormalizedPatchRequest } from "./patch-normalize.server"; const synchronizeAssetResourceIndexQueries = vi.fn(); const synchronizeCanonicalAsset = vi.fn(); const synchronizeAllCanonicalAssetStandardMetadata = vi.fn(); const updateAssetResourceIndexesAfterCanonicalChange = vi.fn(); const createAssetClient = vi.fn(() => ({ resourceIndexStore: { putIfAbsent: vi.fn(async ({ checksum }: { checksum: string }) => ({ status: "created" as const, checksum, })), read: vi.fn(async () => ({ data: { async *[Symbol.asyncIterator]() {}, }, })), }, readFile: vi.fn(async () => ({ data: { async *[Symbol.asyncIterator]() {}, }, })), uploadFile: vi.fn(async () => ({ format: "file", size: 0, meta: {} })), })); const synchronizationDependencies = { synchronizeAssetResourceIndexQueries, synchronizeCanonicalAsset, synchronizeAllCanonicalAssetStandardMetadata, updateAssetResourceIndexesAfterCanonicalChange, createAssetClient, } satisfies Exclude< Parameters[1], undefined >; const patchDependencies = { synchronizeAssetResourcesAfterBuildPatch: ( input: Parameters[0] ) => synchronizeAssetResourcesAfterBuildPatch( input, synchronizationDependencies ), }; const applyPatchRequestForTest = ( context: AppContext, patch: NormalizedPatchRequest ) => applyPatchRequest(context, patch, patchDependencies); 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: JSON.stringify([]), }; 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(); synchronizeAssetResourceIndexQueries.mockReset().mockResolvedValue({ deletedResourceIds: [], updatedResourceIds: [], }); synchronizeCanonicalAsset.mockReset().mockResolvedValue({ status: "indexed", revision: "revision-1", }); synchronizeAllCanonicalAssetStandardMetadata .mockReset() .mockResolvedValue(0); updateAssetResourceIndexesAfterCanonicalChange .mockReset() .mockResolvedValue({ changedAssetIds: [], updatedResourceIds: [], }); createAssetClient.mockClear(); }); test("synchronizes query indexes after a committed resource patch", async () => { const resourceEntry = { ...patch.entries[0], transaction: transaction("tx-resource", "resources") as never, }; authorizePatchEntries.mockResolvedValue({ authorized: [{ entry: resourceEntry, context: { writer: 1 } }], rejected: [], }); const nextResources = JSON.stringify([ { id: "posts", name: "Posts", control: "system", method: "post", url: JSON.stringify("/$resources/assets/query"), headers: [], body: '{query:"*[]"}', }, ]); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4, resources: nextResources }, })); await expect( applyPatchRequestForTest(createContext(), { ...patch, entries: [resourceEntry], }) ).resolves.toMatchObject({ status: "ok" }); expect(synchronizeAssetResourceIndexQueries).toHaveBeenCalledWith( expect.objectContaining({ projectId: "project-1", previousResources: [], resources: [expect.objectContaining({ id: "posts" })], }) ); }); test("does not repeat canonical reads or changed-query builds for a combined patch", 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/query"), headers: [], body: '{query:"*[]"}', }, ]); patchLoadedBuild.mockImplementation(async ({ build }) => ({ status: "ok", version: 4, build: { ...build, version: 4, resources: nextResources }, })); synchronizeAssetResourceIndexQueries.mockResolvedValueOnce({ deletedResourceIds: [], updatedResourceIds: ["posts"], }); await applyPatchRequestForTest(createContext(), { ...patch, entries: [combinedEntry], }); expect(synchronizeCanonicalAsset).not.toHaveBeenCalled(); expect(synchronizeAllCanonicalAssetStandardMetadata).not.toHaveBeenCalled(); expect(updateAssetResourceIndexesAfterCanonicalChange).toHaveBeenCalledWith( expect.objectContaining({ changedAssetIds: ["asset-1"], excludedResourceIds: ["posts"], }) ); }); test("maintains canonical paths and indexes after asset-folder patches", 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" }); expect(synchronizeAllCanonicalAssetStandardMetadata).toHaveBeenCalledWith({ client: expect.anything(), projectId: "project-1", }); expect(updateAssetResourceIndexesAfterCanonicalChange).toHaveBeenCalledWith( expect.objectContaining({ projectId: "project-1", changedAssetIds: ["project-assets"], }) ); }); test("indexes added assets before rebuilding resource indexes", 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" }); expect(synchronizeCanonicalAsset).toHaveBeenCalledWith({ client: expect.anything(), assetClient: createAssetClient.mock.results[0]?.value, projectId: "project-1", assetId: "asset-1", }); expect(updateAssetResourceIndexesAfterCanonicalChange).toHaveBeenCalledWith( expect.objectContaining({ projectId: "project-1", changedAssetIds: ["asset-1"], }) ); expect(synchronizeCanonicalAsset.mock.invocationCallOrder[0]).toBeLessThan( updateAssetResourceIndexesAfterCanonicalChange.mock.invocationCallOrder[0] ); }); test("reparses a swapped asset revision before rebuilding indexes", 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], }); expect(synchronizeCanonicalAsset).toHaveBeenCalledWith( expect.objectContaining({ assetId: "asset-1" }) ); expect(synchronizeCanonicalAsset.mock.invocationCallOrder[0]).toBeLessThan( updateAssetResourceIndexesAfterCanonicalChange.mock.invocationCallOrder[0] ); }); test("does not reparse files after standard asset metadata changes", 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], }); expect(synchronizeCanonicalAsset).not.toHaveBeenCalled(); expect(updateAssetResourceIndexesAfterCanonicalChange).toHaveBeenCalledWith( expect.objectContaining({ changedAssetIds: ["asset-1"] }) ); }); test("skips index maintenance for asset descriptions", 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], }); expect(synchronizeCanonicalAsset).not.toHaveBeenCalled(); expect( updateAssetResourceIndexesAfterCanonicalChange ).not.toHaveBeenCalled(); }); 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("loads only base patch columns for asset-only patches", 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(); }); });