@furystack/repository
Version:
Repository implementation for FuryStack
398 lines (358 loc) • 18.2 kB
text/typescript
/* 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()
})
})
})
})