UNPKG

@furystack/repository

Version:

Repository implementation for FuryStack

398 lines (358 loc) 18.2 kB
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion -- false positives: discriminated `AuthorizationResult` mocks need explicit casts so vi.fn return types match the union (rule's tsutils-based check disagrees with tsc) */ import type { StoreToken, WithOptionalId } from '@furystack/core' import { AuthorizationError, defineStore, InMemoryStore } from '@furystack/core' import { createInjector, type Injector } from '@furystack/inject' import { usingAsync } from '@furystack/utils' import { describe, expect, it, vi } from 'vitest' import type { AuthorizationResult, DataSetSettings } from './data-set-setting.js' import type { DataSet } from './data-set.js' import { defineDataSet, type DefineDataSetSettings } from './define-data-set.js' import { getDataSetFor } from './helpers.js' class TestClass { declare id: number declare value: string } // Token declared at module scope so TypeScript preserves the literal primary // key. The explicit `StoreToken<TestClass, 'id'>` annotation keeps // `TPrimaryKey` narrow when the token is later passed to `defineDataSet`; // inferred return types from `defineStore` tend to widen back to // `keyof TestClass` in downstream inference contexts. const TestStore: StoreToken<TestClass, 'id'> = defineStore({ name: 'test/DataSetSpecStore', model: TestClass, primaryKey: 'id', factory: () => new InMemoryStore({ model: TestClass, primaryKey: 'id' }), }) /** * Builds a fresh injector with a disposable {@link InMemoryStore} bound as the * backing store for a {@link TestClass} dataset. Each call mints a new * dataset token so the injector cache starts from scratch for every test. */ const withDataSet = async ( settings: DefineDataSetSettings<TestClass, 'id'> | undefined, fn: (ctx: { i: Injector; dataSet: DataSet<TestClass, 'id'> }) => Promise<void>, ): Promise<void> => { const TestDataSet = defineDataSet<TestClass, 'id', WithOptionalId<TestClass, 'id'>>({ name: `test/DataSet-${Math.random()}`, store: TestStore, settings, }) await usingAsync(createInjector(), async (i) => { const dataSet = getDataSetFor(i, TestDataSet) await fn({ i, dataSet }) }) } describe('DataSet', () => { describe('Authorizers', () => { describe('Add', () => { it('adds an entity when no settings are provided', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) const result = await dataSet.get(i, 1) expect(result?.value).toBe('asd') }) }) it('runs authorizeAdd and persists the entity on pass', async () => { const authorizeAdd = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult) await withDataSet({ authorizeAdd }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) expect(authorizeAdd).toHaveBeenCalled() expect((await dataSet.get(i, 1))?.value).toBe('asd') }) }) it('throws and does not persist when authorizeAdd fails', async () => { const authorizeAdd = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult) await withDataSet({ authorizeAdd }, async ({ i, dataSet }) => { await expect(dataSet.add(i, { id: 1, value: 'asd' })).rejects.toBeInstanceOf(AuthorizationError) expect(authorizeAdd).toHaveBeenCalled() expect(await dataSet.get(i, 1)).toBeUndefined() }) }) it('applies modifyOnAdd before persisting', async () => { const modifyOnAdd = vi.fn<NonNullable<DataSetSettings<TestClass, 'id'>['modifyOnAdd']>>(async ({ entity }) => ({ ...entity, value: entity.value.toUpperCase(), })) await withDataSet({ modifyOnAdd }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) const result = await dataSet.get(i, 1) expect(modifyOnAdd).toHaveBeenCalled() expect(result?.value).toBe('ASD') }) }) it('emits onEntityAdded after a successful add', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { const listener = vi.fn() dataSet.addListener('onEntityAdded', listener) await dataSet.add(i, { id: 1, value: 'asd' }) expect(listener).toHaveBeenCalledWith(expect.objectContaining({ entity: { id: 1, value: 'asd' } })) }) }) }) describe('Update', () => { it('updates an entity when no settings are provided', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.update(i, 1, { id: 1, value: 'asd2' }) expect((await dataSet.get(i, 1))?.value).toBe('asd2') }) }) it('runs authorizeUpdate and persists on pass', async () => { const authorizeUpdate = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult) await withDataSet({ authorizeUpdate }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.update(i, 1, { id: 1, value: 'asd2' }) expect(authorizeUpdate).toHaveBeenCalled() expect((await dataSet.get(i, 1))?.value).toBe('asd2') }) }) it('throws when authorizeUpdate fails and leaves the entity untouched', async () => { const authorizeUpdate = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult) await withDataSet({ authorizeUpdate }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await expect(dataSet.update(i, 1, { id: 1, value: 'asd2' })).rejects.toBeInstanceOf(AuthorizationError) expect((await dataSet.get(i, 1))?.value).toBe('asd') }) }) it('runs authorizeUpdateEntity against the loaded entity and persists on pass', async () => { const authorizeUpdateEntity = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult) await withDataSet({ authorizeUpdateEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.update(i, 1, { id: 1, value: 'asd2' }) expect(authorizeUpdateEntity).toHaveBeenCalled() expect((await dataSet.get(i, 1))?.value).toBe('asd2') }) }) it('throws and leaves the entity untouched when authorizeUpdateEntity fails', async () => { const authorizeUpdateEntity = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult) await withDataSet({ authorizeUpdateEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await expect(dataSet.update(i, 1, { id: 1, value: 'asd2' })).rejects.toBeInstanceOf(AuthorizationError) expect((await dataSet.get(i, 1))?.value).toBe('asd') }) }) it('applies modifyOnUpdate before persisting', async () => { const modifyOnUpdate = vi.fn<NonNullable<DataSetSettings<TestClass, 'id'>['modifyOnUpdate']>>( async ({ entity }) => ({ ...entity, value: entity.value?.toUpperCase() ?? '', }), ) await withDataSet({ modifyOnUpdate }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.update(i, 1, { id: 1, value: 'asd2' }) expect(modifyOnUpdate).toHaveBeenCalled() expect((await dataSet.get(i, 1))?.value).toBe('ASD2') }) }) it('emits onEntityUpdated with the applied change', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { const listener = vi.fn() dataSet.addListener('onEntityUpdated', listener) await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.update(i, 1, { id: 1, value: 'asd2' }) expect(listener).toHaveBeenCalledWith(expect.objectContaining({ change: { id: 1, value: 'asd2' }, id: 1 })) }) }) }) describe('Count', () => { it('returns the count when no settings are provided', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) expect(await dataSet.count(i)).toBe(1) }) }) it('returns the count when authorizeGet passes', async () => { const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeGet }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) expect(await dataSet.count(i)).toBe(1) }) }) it('throws when authorizeGet fails', async () => { const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult) await withDataSet({ authorizeGet }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await expect(dataSet.count(i)).rejects.toBeInstanceOf(AuthorizationError) }) }) }) }) describe('filter', () => { it('returns the unfiltered result when no settings are provided', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) expect((await dataSet.find(i, {})).length).toBe(1) }) }) it('returns the unfiltered result when authorizeGet passes', async () => { const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeGet }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) expect((await dataSet.find(i, {})).length).toBe(1) }) }) it('throws when authorizeGet fails', async () => { const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult) await withDataSet({ authorizeGet }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await expect(dataSet.find(i, {})).rejects.toBeInstanceOf(AuthorizationError) }) }) }) describe('get', () => { it('returns the entity when no settings are provided', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) expect((await dataSet.get(i, 1))?.id).toBe(1) }) }) it('returns the entity when authorizeGet passes', async () => { const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeGet }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) expect((await dataSet.get(i, 1))?.id).toBe(1) }) }) it('throws when authorizeGet fails', async () => { const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult) await withDataSet({ authorizeGet }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await expect(dataSet.get(i, 1)).rejects.toBeInstanceOf(AuthorizationError) }) }) it('returns the entity when authorizeGetEntity passes', async () => { const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) expect((await dataSet.get(i, 1))?.id).toBe(1) }) }) it('throws when authorizeGetEntity fails', async () => { const authorizeGetEntity = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult) await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await expect(dataSet.get(i, 1)).rejects.toBeInstanceOf(AuthorizationError) }) }) it('receives the full entity in authorizeGetEntity even when select is used', async () => { const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) const result = await dataSet.get(i, 1, ['value']) expect(authorizeGetEntity).toHaveBeenCalledTimes(1) const firstCall = authorizeGetEntity.mock.calls[0] as unknown as [{ entity: TestClass }] expect(firstCall[0].entity).toEqual({ id: 1, value: 'asd' }) expect(result).toEqual({ value: 'asd' }) expect(result).not.toHaveProperty('id') }) }) it('returns undefined without calling authorizeGetEntity when the entity does not exist', async () => { const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => { const result = await dataSet.get(i, 999) expect(result).toBeUndefined() expect(authorizeGetEntity).not.toHaveBeenCalled() }) }) }) describe('remove', () => { it('removes the entity when no settings are provided', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.remove(i, 1) expect(await dataSet.count(i)).toBe(0) }) }) it('removes the entity when authorizeRemove passes', async () => { const authorizeRemove = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeRemove }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.remove(i, 1) expect(await dataSet.count(i)).toBe(0) }) }) it('throws and preserves the entity when authorizeRemove fails', async () => { const authorizeRemove = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult) await withDataSet({ authorizeRemove }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await expect(dataSet.remove(i, 1)).rejects.toBeInstanceOf(AuthorizationError) expect(await dataSet.count(i)).toBe(1) }) }) it('removes the entity when authorizeRemoveEntity passes', async () => { const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.remove(i, 1) expect(await dataSet.count(i)).toBe(0) }) }) it('throws and preserves the entity when authorizeRemoveEntity fails', async () => { const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult) await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'asd' }) await expect(dataSet.remove(i, 1)).rejects.toBeInstanceOf(AuthorizationError) expect(await dataSet.count(i)).toBe(1) }) }) it('emits onEntityRemoved after a successful remove', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { const listener = vi.fn() dataSet.addListener('onEntityRemoved', listener) await dataSet.add(i, { id: 1, value: 'asd' }) await dataSet.remove(i, 1) expect(listener).toHaveBeenCalledWith(expect.objectContaining({ key: 1 })) }) }) it('removes multiple entities at once', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' }, { id: 3, value: 'c' }) await dataSet.remove(i, 1, 3) expect(await dataSet.count(i)).toBe(1) expect((await dataSet.get(i, 2))?.value).toBe('b') }) }) it('emits onEntityRemoved for each removed key', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { const removedKeys: number[] = [] dataSet.addListener('onEntityRemoved', ({ key }) => { removedKeys.push(key) }) await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' }) await dataSet.remove(i, 1, 2) expect(removedKeys).toEqual([1, 2]) }) }) it('authorizes each entity when authorizeRemoveEntity is set', async () => { const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult) await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' }) await dataSet.remove(i, 1, 2) expect(authorizeRemoveEntity).toHaveBeenCalledTimes(2) expect(await dataSet.count(i)).toBe(0) }) }) it('does not remove anything when authorizeRemoveEntity fails for one entity (all-or-nothing)', async () => { const authorizeRemoveEntity = vi.fn(async ({ entity }: { entity: TestClass }) => { if (entity.id === 2) { return { isAllowed: false, message: 'forbidden' } as AuthorizationResult } return { isAllowed: true } as AuthorizationResult }) await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => { await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' }, { id: 3, value: 'c' }) await expect(dataSet.remove(i, 1, 2, 3)).rejects.toBeInstanceOf(AuthorizationError) expect(await dataSet.count(i)).toBe(3) }) }) it('no-ops when called without keys', async () => { await withDataSet(undefined, async ({ i, dataSet }) => { const listener = vi.fn() dataSet.addListener('onEntityRemoved', listener) await dataSet.remove(i) expect(listener).not.toHaveBeenCalled() }) }) }) })