231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { createPluginMarketplaceStore } from '@/stores/plugin-marketplace';
|
|
import type { MarketplaceLibraryProjection, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace';
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((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<MarketplaceLibraryProjection>(), deferred<MarketplaceLibraryProjection>(), deferred<MarketplaceLibraryProjection>()];
|
|
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<MarketplaceLibraryProjection>();
|
|
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<MarketplaceLibraryProjection>();
|
|
const second = deferred<MarketplaceLibraryProjection>();
|
|
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<MarketplaceLibraryProjection>();
|
|
const b = deferred<MarketplaceLibraryProjection>();
|
|
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<MarketplaceLibraryProjection>();
|
|
const install = deferred<MarketplaceInstallation>();
|
|
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<MarketplaceLibraryProjection>();
|
|
const install = deferred<MarketplaceInstallation>();
|
|
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',
|
|
});
|
|
});
|
|
});
|