import { expect, it } from "bun:test"; import { AccessPolicy, MultiplayerStateManager, MultiplayerStateManagerError, NoyaManager, ServerComputed, Static, Type, stubSync, } from ".."; it("listens to state", () => { const noyaManager = new NoyaManager({ count: 0 }); const unsync = stubSync<{ count: number }, {}, {}>()({ noyaManager, }); const ms = noyaManager.multiplayerStateManager; const values: number[] = []; const unsubscribe = ms.optimisticState$.subscribe((state) => { values.push(state.count); }); expect(values).toEqual([0]); // Initial value ms.setState({ count: 1 }); expect(values).toEqual([0, 1]); // No change ms.setState({ count: 1 }); expect(values).toEqual([0, 1]); ms.setState({ count: 2 }); expect(values).toEqual([0, 1, 2]); unsubscribe(); unsync(); }); it("listens to specific path", () => { const noyaManager = new NoyaManager({ count: 0 }); const unsync = stubSync<{ count: number }, {}, {}>()({ noyaManager, }); const ms = noyaManager.multiplayerStateManager; const values: number[] = []; const unsubscribe = ms.optimisticState$.subscribe("count", (count) => { values.push(count); }); expect(values).toEqual([0]); // Initial value ms.setState({ count: 1 }); expect(values).toEqual([0, 1]); // No change ms.setState({ count: 1 }); expect(values).toEqual([0, 1]); ms.setState({ count: 2 }); expect(values).toEqual([0, 1, 2]); unsubscribe(); expect(ms.optimisticState$.listeners.length).toEqual(0); unsync(); }); it("setStateAtPath", () => { const noyaManager = new NoyaManager({ count: 0 }); const unsync = stubSync<{ count: number }, {}, {}>()({ noyaManager, }); const ms = noyaManager.multiplayerStateManager; const values: number[] = []; const unsubscribe = ms.optimisticState$.subscribe("count", (count) => { values.push(count); }); expect(values).toEqual([0]); // Initial value ms.setStateAtPath("count", 1); expect(values).toEqual([0, 1]); expect(ms.optimisticState$.get()).toEqual({ count: 1, }); // Set state at path with function ms.setStateAtPath("count", (count) => count + 1); expect(values).toEqual([0, 1, 2]); expect(ms.optimisticState$.get()).toEqual({ count: 2, }); ms.undo(); expect(values).toEqual([0, 1, 2, 1]); expect(ms.optimisticState$.get()).toEqual({ count: 1, }); unsubscribe(); unsync(); }); it("handles erroneous double acceptPatch", () => { const noyaManager = new NoyaManager({ numbers: [0] }); const unsync = stubSync<{ numbers: number[] }, {}, {}>({ debugOptions: { DEBUG_acceptSamePatchTwice: true, }, })({ noyaManager, }); const ms = noyaManager.multiplayerStateManager; ms.editDraft((draft) => { draft.numbers.push(1); }); expect(ms.optimisticState$.get()).toEqual({ numbers: [0, 1] }); expect(ms.pendingCommits.length).toEqual(0); unsync(); }); it("applies server-augmented acceptPatch", () => { type Todo = { id: string; value: number; updatedAt?: number }; const noyaManager = new NoyaManager<{ todos: Todo[] }>({ todos: [{ id: "1", value: 0 }], }); const serverTimestamp = 123; const unsync = stubSync<{ todos: Todo[] }, {}, {}>({ debugOptions: { transformPatches: (patches) => [ ...patches, { op: "add", path: ["todos", { id: "1" }, "updatedAt"], value: serverTimestamp, }, ], }, })({ noyaManager, }); const ms = noyaManager.multiplayerStateManager; ms.editDraft((draft) => { draft.todos[0].value = 1; }); expect(ms.pendingCommits.length).toEqual(0); expect(ms.getConfirmedState()).toEqual({ todos: [{ id: "1", value: 1, updatedAt: serverTimestamp }], }); unsync(); }); it("setState uses diff to create patch", () => { type Player = { id: string; score: number }; type Players = Player[]; type App = { players: Players }; const noyaManager = new NoyaManager({ players: [] }); const unsync = stubSync()({ noyaManager, }); const ms = noyaManager.multiplayerStateManager; ms.setState({ players: [{ id: "1", score: 10 }] }); expect(ms.sm.history.at(-1)?.redoPatches).toEqual([ { op: "add", path: ["players", 0], value: { id: "1", score: 10 }, }, ]); ms.setState({ players: [{ id: "1", score: 20 }] }); expect(ms.sm.history.at(-1)?.redoPatches).toEqual([ { op: "replace", path: ["players", { id: "1" }, "score"], value: 20, }, ]); unsync(); }); it("setStateAtPath uses diff to create patch", () => { type Player = { id: string; score: number }; type Players = Player[]; type App = { players: Players }; const noyaManager = new NoyaManager({ players: [] }); const unsync = stubSync()({ noyaManager, }); const ms = noyaManager.multiplayerStateManager; ms.setState({ players: [{ id: "1", score: 10 }] }); expect(ms.sm.history.at(-1)?.redoPatches).toEqual([ { op: "add", path: ["players", 0], value: { id: "1", score: 10 }, }, ]); ms.setStateAtPath(["players"] as const, [{ id: "1", score: 20 }]); expect(ms.sm.history.at(-1)?.redoPatches).toEqual([ { op: "replace", path: ["players", { id: "1" }, "score"], value: 20, }, ]); unsync(); }); it("prevents local edits for read-only access before contacting the server", () => { const manager = new MultiplayerStateManager<{ value: number }>({ value: 0, }); manager.setPolicyAuthContext({ id: "reader", access: "read" }); manager.connect(); const errors: MultiplayerStateManagerError[] = []; const unsubscribe = manager.errorEmitter.addListener((error) => { errors.push(error); }); manager.setState({ value: 1 }); expect(errors.at(-1)?.reason).toEqual("policyViolation"); expect(manager.getConfirmedState()).toEqual({ value: 0 }); expect(manager.pendingCommits.length).toEqual(0); unsubscribe(); manager.disconnect(); }); it("prevents local edits that violate access policies", () => { const schema = Type.Object({ todos: Type.Array( AccessPolicy( Type.Object({ id: Type.String(), title: Type.String(), creatorId: Type.String(), }), { allow: { update: "auth.id != null && data != null && data.creatorId == auth.id", }, } ) ), }); type TodoState = Static; const manager = new MultiplayerStateManager( { todos: [ { id: "todo-1", title: "Sketch", creatorId: "owner" }, { id: "todo-2", title: "Ship", creatorId: "owner" }, ], }, { schema } ); manager.setPolicyAuthContext({ id: "intruder", access: "write" }); manager.connect(); const errors: MultiplayerStateManagerError[] = []; const unsubscribe = manager.errorEmitter.addListener((error) => { errors.push(error); }); manager.editDraft((draft) => { draft.todos[0].title = "Hacked"; }); expect(errors.at(-1)?.reason).toEqual("policyViolation"); expect(manager.getConfirmedState()).toEqual({ todos: [ { id: "todo-1", title: "Sketch", creatorId: "owner" }, { id: "todo-2", title: "Ship", creatorId: "owner" }, ], }); expect(manager.pendingCommits.length).toEqual(0); unsubscribe(); manager.disconnect(); }); it("skips local policy enforcement when auth context is missing", () => { const schema = Type.Object({ todos: Type.Array( AccessPolicy( Type.Object({ id: Type.String(), title: Type.String(), creatorId: Type.String(), }), { allow: { update: "auth.id != null && data != null && data.creatorId == auth.id", }, } ) ), }); type TodoState = Static; const manager = new MultiplayerStateManager( { todos: [{ id: "todo-1", title: "Sketch", creatorId: "owner" }], }, { schema } ); manager.connect(); const errors: MultiplayerStateManagerError[] = []; const unsubscribe = manager.errorEmitter.addListener((error) => { errors.push(error); }); manager.editDraft((draft) => { draft.todos[0].title = "Offline edit"; }); expect(errors.length).toEqual(0); expect(manager.pendingCommits.length).toEqual(1); unsubscribe(); manager.disconnect(); }); it("allows local edits that rely on server-computed metadata", () => { const schema = Type.Object({ todos: Type.Array( AccessPolicy( Type.Object({ id: Type.String(), title: Type.String(), createdAt: Type.Optional( ServerComputed(Type.String(), { compute: { kind: "timestamp" }, events: ["create"], targets: ["object"], }) ), }), { allow: { create: "newData != null && newData.createdAt != null", }, } ) ), }); type TodoState = Static; const manager = new MultiplayerStateManager( { todos: [] }, { schema } ); manager.setPolicyAuthContext({ id: "owner", access: "write" }); manager.connect(); const errors: MultiplayerStateManagerError[] = []; const unsubscribe = manager.errorEmitter.addListener((error) => { errors.push(error); }); manager.editDraft((draft) => { draft.todos.push({ id: "todo-1", title: "Draft" }); }); expect(errors.length).toEqual(0); expect(manager.pendingCommits.length).toEqual(1); unsubscribe(); manager.disconnect(); });