import { describe, expect, it, vi } from 'vitest'; import { createPluginMarketplaceStore } from '@/stores/plugin-marketplace'; import type { MarketplaceLibraryProjection, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace'; function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } function library(title: string): MarketplaceLibrarySnapshot { return { total: 1, stale: false, fetchedAt: 1, items: [{ pluginId: 'makelore.notes', title, summary: 'Notes', category: 'tools', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null, }], }; } const projection = (title: string): MarketplaceLibraryProjection => ({ library: library(title), installations: [], }); describe('plugin Marketplace store', () => { it('prevents stale A → B → A account loads from committing', async () => { const requests = [deferred(), deferred(), deferred()]; const readLibrary = vi.fn(() => requests[readLibrary.mock.calls.length - 1].promise); const store = createPluginMarketplaceStore({ readLibrary }); store.getState().activateAccount('account-a'); const a1 = store.getState().loadLibrary(); store.getState().activateAccount('account-b'); const b = store.getState().loadLibrary(); store.getState().activateAccount('account-a'); const a2 = store.getState().loadLibrary(); requests[2].resolve(projection('A newest')); await a2; requests[1].resolve(projection('B stale')); requests[0].resolve(projection('A oldest')); await Promise.all([a1, b]); expect(store.getState().accountKey).toBe('account-a'); expect(store.getState().library?.items[0].title).toBe('A newest'); }); it('clears account projections immediately on logout', async () => { const store = createPluginMarketplaceStore({ readLibrary: vi.fn().mockResolvedValue(projection('A')) }); store.getState().activateAccount('account-a'); await store.getState().loadLibrary(); store.getState().activateAccount(null); expect(store.getState()).toMatchObject({ accountKey: null, library: null, installations: {} }); }); it('does not let a load started before a mutation overwrite the mutation result', async () => { const stale = deferred(); const store = createPluginMarketplaceStore({ readLibrary: vi.fn(() => stale.promise), acquire: vi.fn().mockResolvedValue(projection('Acquired')), }); store.getState().activateAccount('account-a'); const load = store.getState().loadLibrary(); await store.getState().acquire('makelore.notes'); stale.resolve(projection('Old')); await load; expect(store.getState().library?.items[0].title).toBe('Acquired'); }); it('keeps the newest same-account Library read when an older response completes last', async () => { const first = deferred(); const second = deferred(); const readLibrary = vi.fn() .mockImplementationOnce(() => first.promise) .mockImplementationOnce(() => second.promise); const store = createPluginMarketplaceStore({ readLibrary }); store.getState().activateAccount('account-a'); const oldest = store.getState().loadLibrary(); const newest = store.getState().loadLibrary(); second.resolve(projection('newest')); await newest; first.resolve(projection('oldest')); await oldest; expect(store.getState().library?.items[0].title).toBe('newest'); }); it('does not let an older mutation response overwrite a newer mutation for another plugin', async () => { const make = (pluginId: string, title: string): MarketplaceLibraryProjection => ({ library: { total: 1, stale: false, fetchedAt: 1, items: [{ pluginId, title, summary: title, category: 'tools', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null, }], }, installations: [], }); for (const order of ['a-then-b', 'b-then-a'] as const) { const a = deferred(); const b = deferred(); const store = createPluginMarketplaceStore({ acquire: vi.fn((pluginId: string) => pluginId === 'makelore.a' ? a.promise : b.promise), }); store.getState().activateAccount('account-a'); const aRequest = store.getState().acquire('makelore.a'); const bRequest = store.getState().acquire('makelore.b'); if (order === 'a-then-b') { a.resolve(make('makelore.a', 'A older')); await aRequest; b.resolve(make('makelore.b', 'B newer')); await bRequest; } else { b.resolve(make('makelore.b', 'B newer')); await bRequest; a.resolve(make('makelore.a', 'A older')); await aRequest; } expect(store.getState().library?.items[0].title).toBe('B newer'); expect(store.getState().library?.items.map(({ pluginId }) => pluginId).sort()).toEqual([ 'makelore.a', 'makelore.b', ]); } }); it('keeps independent Library and device mutations when their responses complete out of order', async () => { const acquire = deferred(); const install = deferred(); const store = createPluginMarketplaceStore({ acquire: vi.fn(() => acquire.promise), install: vi.fn(() => install.promise), }); store.getState().activateAccount('account-a'); const libraryMutation = store.getState().acquire('makelore.notes'); const deviceMutation = store.getState().install('makelore.data'); install.resolve({ status: 'installed', pluginId: 'makelore.data', version: '1.0.0' }); await deviceMutation; acquire.resolve({ library: library('Notes acquired'), installations: [], }); await libraryMutation; expect(store.getState().library?.items[0]?.title).toBe('Notes acquired'); expect(store.getState().installations['makelore.data']).toMatchObject({ status: 'installed' }); }); it('merges same-plugin Library and device mutations in either completion order', async () => { for (const order of ['library-then-device', 'device-then-library'] as const) { const acquire = deferred(); const install = deferred(); const store = createPluginMarketplaceStore({ acquire: vi.fn(() => acquire.promise), install: vi.fn(() => install.promise), }); store.getState().activateAccount('account-a'); const libraryMutation = store.getState().acquire('makelore.notes'); const deviceMutation = store.getState().install('makelore.notes'); const installed = { status: 'installed' as const, pluginId: 'makelore.notes', version: '1.0.0' }; const acquired = { library: library('Notes acquired'), installations: [] }; if (order === 'library-then-device') { acquire.resolve(acquired); await libraryMutation; install.resolve(installed); await deviceMutation; } else { install.resolve(installed); await deviceMutation; acquire.resolve(acquired); await libraryMutation; } expect(store.getState().library?.items[0]?.title).toBe('Notes acquired'); expect(store.getState().installations['makelore.notes']).toMatchObject(installed); } }); it('calls only the selected state mutation', async () => { const acquire = vi.fn().mockResolvedValue(projection('Acquired')); const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'makelore.notes', version: '1.0.0' }); const setProjectEnabled = vi.fn(); const store = createPluginMarketplaceStore({ acquire, install }); store.getState().activateAccount('account-a'); await store.getState().acquire('makelore.notes'); expect(acquire).toHaveBeenCalledOnce(); expect(install).not.toHaveBeenCalled(); expect(setProjectEnabled).not.toHaveBeenCalled(); await store.getState().install('makelore.notes'); expect(install).toHaveBeenCalledOnce(); expect(setProjectEnabled).not.toHaveBeenCalled(); }); it('projects a bounded installation failure on the affected My Plugins entry', async () => { const error = Object.assign(new Error('Release signature is invalid'), { code: 'plugin_signature_invalid', }); const store = createPluginMarketplaceStore({ install: vi.fn().mockRejectedValue(error), }); store.getState().activateAccount('account-a'); await expect(store.getState().install('makelore.notes')).rejects.toBe(error); expect(store.getState().installations['makelore.notes']).toMatchObject({ status: 'unavailable', pluginId: 'makelore.notes', reason: 'plugin_signature_invalid: Release signature is invalid', }); }); it('preserves the installed Beta channel when a device update fails', async () => { const error = Object.assign(new Error('Release is temporarily unavailable'), { code: 'plugin_release_unavailable', }); const store = createPluginMarketplaceStore({ installBeta: vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'makelore.notes', releaseId: 'release-beta-1', version: '2.0.0-beta.1', channel: 'beta', }), update: vi.fn().mockRejectedValue(error), }); store.getState().activateAccount('account-a'); await store.getState().installBeta('makelore.notes'); await expect(store.getState().update('makelore.notes')).rejects.toBe(error); expect(store.getState().installations['makelore.notes']).toMatchObject({ status: 'unavailable', pluginId: 'makelore.notes', releaseId: 'release-beta-1', version: '2.0.0-beta.1', channel: 'beta', reason: 'plugin_release_unavailable: Release is temporarily unavailable', }); }); });