From 7fc56323d2542983cc50239f37ab19aec0ecd181 Mon Sep 17 00:00:00 2001 From: Gerard Date: Wed, 1 Apr 2026 16:11:11 +0200 Subject: [PATCH] feat: add @script-development/fs-adapter-store package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reactive adapter-store pattern with domain state management, CRUD resource adapters, caching, and localStorage persistence. Uses reactive getter pattern for existing resources. Generic New defaults to Omit — territories override. No case conversion — middleware handles snake/camel transformation. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 24 + packages/adapter-store/package.json | 56 + packages/adapter-store/src/adapter-store.ts | 91 ++ packages/adapter-store/src/errors.ts | 13 + packages/adapter-store/src/index.ts | 13 + .../adapter-store/src/resource-adapter.ts | 145 +++ packages/adapter-store/src/types.ts | 82 ++ .../adapter-store/tests/adapter-store.spec.ts | 1128 ++++++++++++++++ packages/adapter-store/tests/errors.spec.ts | 42 + .../tests/resource-adapter.spec.ts | 1160 +++++++++++++++++ packages/adapter-store/tsconfig.json | 8 + packages/adapter-store/tsdown.config.ts | 9 + packages/adapter-store/vitest.config.ts | 17 + 13 files changed, 2788 insertions(+) create mode 100644 packages/adapter-store/package.json create mode 100644 packages/adapter-store/src/adapter-store.ts create mode 100644 packages/adapter-store/src/errors.ts create mode 100644 packages/adapter-store/src/index.ts create mode 100644 packages/adapter-store/src/resource-adapter.ts create mode 100644 packages/adapter-store/src/types.ts create mode 100644 packages/adapter-store/tests/adapter-store.spec.ts create mode 100644 packages/adapter-store/tests/errors.spec.ts create mode 100644 packages/adapter-store/tests/resource-adapter.spec.ts create mode 100644 packages/adapter-store/tsconfig.json create mode 100644 packages/adapter-store/tsdown.config.ts create mode 100644 packages/adapter-store/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index cecf21b..854a732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2748,6 +2748,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@script-development/fs-adapter-store": { + "resolved": "packages/adapter-store", + "link": true + }, "node_modules/@script-development/fs-helpers": { "resolved": "packages/helpers", "link": true @@ -8018,6 +8022,26 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/adapter-store": { + "name": "@script-development/fs-adapter-store", + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "@script-development/fs-helpers": "^0.1.0", + "@script-development/fs-http": "^0.1.0", + "@script-development/fs-loading": "^0.1.0", + "@script-development/fs-storage": "^0.1.0", + "jsdom": "^29.0.1", + "vue": "^3.5.0" + }, + "peerDependencies": { + "@script-development/fs-helpers": "^0.1.0", + "@script-development/fs-http": "^0.1.0", + "@script-development/fs-loading": "^0.1.0", + "@script-development/fs-storage": "^0.1.0", + "vue": "^3.5.0" + } + }, "packages/helpers": { "name": "@script-development/fs-helpers", "version": "0.1.0", diff --git a/packages/adapter-store/package.json b/packages/adapter-store/package.json new file mode 100644 index 0000000..2f12dfc --- /dev/null +++ b/packages/adapter-store/package.json @@ -0,0 +1,56 @@ +{ + "name": "@script-development/fs-adapter-store", + "version": "0.1.0", + "description": "Reactive adapter-store pattern with domain state management and CRUD resource adapters", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/script-development/fs-packages.git", + "directory": "packages/adapter-store" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "lint:pkg": "publint && attw --pack", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@script-development/fs-helpers": "^0.1.0", + "@script-development/fs-http": "^0.1.0", + "@script-development/fs-loading": "^0.1.0", + "@script-development/fs-storage": "^0.1.0", + "jsdom": "^29.0.1", + "vue": "^3.5.0" + }, + "peerDependencies": { + "@script-development/fs-helpers": "^0.1.0", + "@script-development/fs-http": "^0.1.0", + "@script-development/fs-loading": "^0.1.0", + "@script-development/fs-storage": "^0.1.0", + "vue": "^3.5.0" + } +} diff --git a/packages/adapter-store/src/adapter-store.ts b/packages/adapter-store/src/adapter-store.ts new file mode 100644 index 0000000..3514766 --- /dev/null +++ b/packages/adapter-store/src/adapter-store.ts @@ -0,0 +1,91 @@ +import type { + Adapted, + AdapterStoreConfig, + AdapterStoreModule, + Item, + NewAdapted, + StoreModuleForAdapter, +} from "./types"; +import type { ComputedRef, Ref } from "vue"; + +import { computed, ref } from "vue"; + +import { EntryNotFoundError } from "./errors"; + +export const createAdapterStoreModule = < + T extends Item, + E extends Adapted = Adapted, + N extends NewAdapted = NewAdapted, +>( + config: AdapterStoreConfig, +): StoreModuleForAdapter => { + const { domainName, adapter, httpService, storageService, loadingService } = config; + + const storedItems = storageService.get<{ [id: number]: T }>(domainName, {}); + const frozenStoredItems = Object.fromEntries( + Object.entries(storedItems).map(([id, item]) => [id, Object.freeze(item)]), + ) as { [id: number]: Readonly }; + + const state: Ref<{ [id: number]: Readonly }> = ref(frozenStoredItems); + + const adaptedCache = new Map(); + const getByIdComputedCache = new Map>(); + + const getAdapted = (item: Readonly): E => { + const cached = adaptedCache.get(item.id); + if (cached) { + return cached; + } + const adapted = adapter(storeModule, () => state.value[item.id] as T); + adaptedCache.set(item.id, adapted); + return adapted; + }; + + const setById = (item: T): void => { + state.value = { ...state.value, [item.id]: Object.freeze(item) }; + storageService.put(domainName, state.value); + }; + + const deleteById = (id: number): void => { + state.value = Object.fromEntries( + Object.entries(state.value).filter(([key]) => Number(key) !== id), + ) as { [id: number]: Readonly }; + storageService.put(domainName, state.value); + adaptedCache.delete(id); + getByIdComputedCache.delete(id); + }; + + const storeModule: AdapterStoreModule = { setById, deleteById }; + + const getById = (id: number): ComputedRef => { + const cached = getByIdComputedCache.get(id); + if (cached) { + return cached; + } + const computedRef = computed(() => (state.value[id] ? getAdapted(state.value[id]) : undefined)); + getByIdComputedCache.set(id, computedRef); + return computedRef; + }; + + return { + getAll: computed(() => Object.values(state.value).map((item) => getAdapted(item))), + getById, + getOrFailById: async (id: number) => { + await loadingService.ensureLoadingFinished(); + const item = getById(id).value; + if (!item) throw new EntryNotFoundError(domainName, id); + return item; + }, + generateNew: () => adapter(storeModule), + retrieveAll: async () => { + const { data } = await httpService.getRequest(domainName); + state.value = data.reduce<{ [id: number]: Readonly }>((acc, item) => { + acc[item.id] = Object.freeze(item); + return acc; + }, {}); + storageService.put(domainName, state.value); + adaptedCache.clear(); + getByIdComputedCache.clear(); + }, + }; +}; diff --git a/packages/adapter-store/src/errors.ts b/packages/adapter-store/src/errors.ts new file mode 100644 index 0000000..dadeae6 --- /dev/null +++ b/packages/adapter-store/src/errors.ts @@ -0,0 +1,13 @@ +export class EntryNotFoundError extends Error { + constructor(domainName: string, id: number) { + super(`${domainName} with id ${id} not found`); + this.name = "EntryNotFoundError"; + } +} + +export class MissingResponseDataError extends Error { + constructor(message: string) { + super(message); + this.name = "MissingResponseDataError"; + } +} diff --git a/packages/adapter-store/src/index.ts b/packages/adapter-store/src/index.ts new file mode 100644 index 0000000..120ab21 --- /dev/null +++ b/packages/adapter-store/src/index.ts @@ -0,0 +1,13 @@ +export { createAdapterStoreModule } from "./adapter-store"; +export { resourceAdapter } from "./resource-adapter"; +export { EntryNotFoundError, MissingResponseDataError } from "./errors"; +export type { + Item, + DefaultNew, + Adapted, + NewAdapted, + Adapter, + AdapterStoreModule, + AdapterStoreConfig, + StoreModuleForAdapter, +} from "./types"; diff --git a/packages/adapter-store/src/resource-adapter.ts b/packages/adapter-store/src/resource-adapter.ts new file mode 100644 index 0000000..e46d2eb --- /dev/null +++ b/packages/adapter-store/src/resource-adapter.ts @@ -0,0 +1,145 @@ +import type { HttpService } from "@script-development/fs-http"; +import type { Writable } from "@script-development/fs-helpers"; +import type { Ref } from "vue"; + +import type { Adapted, AdapterStoreModule, Item, NewAdapted } from "./types"; + +import { deepCopy } from "@script-development/fs-helpers"; +import { ref } from "vue"; + +import { MissingResponseDataError } from "./errors"; + +type ResourceHttpService = Pick< + HttpService, + "postRequest" | "putRequest" | "patchRequest" | "deleteRequest" +>; + +interface AdapterRepository { + create: (newItem: N) => Promise; + update: (id: number, updatedItem: N | T) => Promise; + patch: (id: number, partialItem: Partial) => Promise; + delete: (id: number) => Promise; +} + +const adapterRepositoryFactory = ( + domainName: string, + { setById, deleteById }: AdapterStoreModule, + httpService: ResourceHttpService, +): AdapterRepository => { + const dataHandler = (data: T | undefined, actionType: "create" | "update" | "patch"): T => { + if (!data) { + throw new MissingResponseDataError( + `${actionType} route for ${domainName} returned no model in response to put in store.`, + ); + } + + setById(data); + + return data; + }; + + return { + create: async (newItem: N) => { + const { data } = await httpService.postRequest(domainName, newItem); + return dataHandler(data, "create"); + }, + update: async (id: number, updatedItem: N | T) => { + const { data } = await httpService.putRequest(`${domainName}/${id}`, updatedItem); + return dataHandler(data, "update"); + }, + patch: async (id: number, partialItem: Partial) => { + const { data } = await httpService.patchRequest(`${domainName}/${id}`, partialItem); + return dataHandler(data, "patch"); + }, + delete: async (id: number) => { + await httpService.deleteRequest(`${domainName}/${id}`); + deleteById(id); + }, + }; +}; + +/** + * Resource adapter factory — wraps a domain resource with mutable state and CRUD methods. + * + * Overloaded: + * - With resourceGetter `() => T`: creates an Adapted (existing resource with update/patch/delete) + * - Without: creates a NewAdapted (new resource with create) + */ +export function resourceAdapter>( + resourceGetter: () => T, + domainName: string, + storeModule: AdapterStoreModule, + httpService: ResourceHttpService, +): Adapted; +export function resourceAdapter>( + resource: N, + domainName: string, + storeModule: AdapterStoreModule, + httpService: ResourceHttpService, +): NewAdapted; +export function resourceAdapter>( + resource: (() => T) | N, + domainName: string, + storeModule: AdapterStoreModule, + httpService: ResourceHttpService, +): Adapted | NewAdapted { + const repository = adapterRepositoryFactory(domainName, storeModule, httpService); + + if (typeof resource === "function") { + const resourceGetter = resource as () => T; + const mutable = ref(deepCopy(resourceGetter())) as Ref>; + + const adapted = {} as Adapted; + const source = resourceGetter(); + + for (const key of Object.keys(source)) { + Object.defineProperty(adapted, key, { + get: () => resourceGetter()[key as keyof T], + enumerable: true, + configurable: true, + }); + } + + Object.defineProperty(adapted, "mutable", { + value: mutable, + enumerable: true, + configurable: true, + writable: false, + }); + Object.defineProperty(adapted, "reset", { + value: () => (mutable.value = deepCopy(resourceGetter())), + enumerable: true, + configurable: true, + writable: false, + }); + Object.defineProperty(adapted, "update", { + value: () => repository.update(resourceGetter().id, mutable.value as N | T), + enumerable: true, + configurable: true, + writable: false, + }); + Object.defineProperty(adapted, "patch", { + value: (partialItem: Partial) => repository.patch(resourceGetter().id, partialItem), + enumerable: true, + configurable: true, + writable: false, + }); + Object.defineProperty(adapted, "delete", { + value: () => repository.delete(resourceGetter().id), + enumerable: true, + configurable: true, + writable: false, + }); + + return adapted; + } + + const mutable = ref(deepCopy(resource)) as Ref>; + + return { + ...Object.freeze(resource as object), + mutable, + reset: () => (mutable.value = deepCopy(resource)), + create: () => repository.create(mutable.value as N), + } as unknown as NewAdapted; +} diff --git a/packages/adapter-store/src/types.ts b/packages/adapter-store/src/types.ts new file mode 100644 index 0000000..ed95e4f --- /dev/null +++ b/packages/adapter-store/src/types.ts @@ -0,0 +1,82 @@ +import type { HttpService } from "@script-development/fs-http"; +import type { LoadingService } from "@script-development/fs-loading"; +import type { StorageService } from "@script-development/fs-storage"; +import type { Writable } from "@script-development/fs-helpers"; +import type { ComputedRef, Ref } from "vue"; + +/** Base constraint for all domain items — must have a numeric id. */ +export type Item = { + id: number; +}; + +/** Default type for new resources — strips the id field. Territories can override. */ +export type DefaultNew = Omit; + +/** + * Internal store module contract passed to adapters. + * NOT part of the public API — adapters use this to mutate store state + * after successful CRUD operations. + */ +export type AdapterStoreModule = { + setById: (item: T) => void; + deleteById: (id: number) => void; +}; + +/** Base of a resource adapter: readonly resource + mutable ref + reset. */ +type BaseResourceAdapter = Readonly & { + /** Reactive, mutable copy of the resource. */ + mutable: Ref>; + /** Reset the mutable state to the original resource. */ + reset: () => void; +}; + +/** Adapter for an existing resource. Provides update, patch, and delete. */ +export type Adapted> = BaseResourceAdapter & { + update(): Promise; + patch(partialItem: Partial): Promise; + delete(): Promise; +}; + +/** Adapter for a new resource (without id). Provides create. */ +export type NewAdapted< + T extends Item, + N extends object = DefaultNew, +> = BaseResourceAdapter & { + create(): Promise; +}; + +/** Callable adapter type — overloaded for existing vs new resources. */ +export type Adapter< + T extends Item, + E extends Adapted, + N extends NewAdapted, +> = { + (storeModule: AdapterStoreModule): N; + (storeModule: AdapterStoreModule, resourceGetter: () => T): E; +}; + +/** Configuration for createAdapterStoreModule. */ +export type AdapterStoreConfig< + T extends Item, + E extends Adapted, + N extends NewAdapted, +> = { + domainName: string; + adapter: Adapter; + httpService: Pick; + storageService: Pick; + loadingService: Pick; +}; + +/** Public API of a store module. */ +export type StoreModuleForAdapter< + T extends Item, + E extends Adapted, + N extends NewAdapted, +> = { + getAll: ComputedRef; + getById: (id: number) => ComputedRef; + getOrFailById: (id: number) => Promise; + generateNew: () => N; + retrieveAll: () => Promise; +}; diff --git a/packages/adapter-store/tests/adapter-store.spec.ts b/packages/adapter-store/tests/adapter-store.spec.ts new file mode 100644 index 0000000..b0ede55 --- /dev/null +++ b/packages/adapter-store/tests/adapter-store.spec.ts @@ -0,0 +1,1128 @@ +import type { HttpService } from "@script-development/fs-http"; +import type { StorageService } from "@script-development/fs-storage"; +import type { LoadingService } from "@script-development/fs-loading"; +import type { + Adapted, + Adapter, + AdapterStoreConfig, + AdapterStoreModule, + Item, + NewAdapted, +} from "../src/types"; +import type { AxiosResponse } from "axios"; +import type { Ref } from "vue"; + +import { EntryNotFoundError } from "../src/errors"; +import { createAdapterStoreModule } from "../src/adapter-store"; +import { describe, expect, it, vi } from "vitest"; +import { ref } from "vue"; + +type TestNew = Omit; +type TestStorageService = Pick; +type TestLoadingService = Pick; + +interface TestItem extends Item { + id: number; + name: string; + createdAt: string; + updatedAt: string; +} + +type TestAdapted = Adapted & { testMethod: () => string }; +type TestNewAdapted = NewAdapted & { testMethod: () => string }; + +/** + * Mock adapter function for tests. + * + * Exception to test encapsulation rule: This adapter is defined globally because + * inlining it in each test (~25 lines) severely impacts readability. The adapter + * is stateless and does not affect test isolation. + */ +function createTestAdapter(storeModule: AdapterStoreModule): TestNewAdapted; +function createTestAdapter( + storeModule: AdapterStoreModule, + resourceGetter: () => TestItem, +): TestAdapted; +function createTestAdapter( + storeModule: AdapterStoreModule, + resourceGetter?: () => TestItem, +): TestAdapted | TestNewAdapted { + if (resourceGetter) { + const adapted = {} as TestAdapted; + const source = resourceGetter(); + + for (const key of Object.keys(source)) { + Object.defineProperty(adapted, key, { + get: () => resourceGetter()[key as keyof TestItem], + enumerable: true, + configurable: false, + }); + } + + Object.defineProperty(adapted, "mutable", { + value: ref({ ...resourceGetter() }) as Ref, + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "reset", { + value: vi.fn(), + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "update", { + value: vi.fn(), + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "patch", { + value: vi.fn(), + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "delete", { + value: vi.fn(), + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "testMethod", { + value: () => `adapted-${resourceGetter().id}`, + enumerable: true, + configurable: false, + writable: false, + }); + + return adapted; + } + return { + name: "", + mutable: ref({ name: "" }) as Ref, + reset: vi.fn(), + create: vi.fn(), + testMethod: () => "new-adapted", + } as unknown as TestNewAdapted; +} + +/** + * Creates a capturing adapter that stores the storeModule for later access. + * + * Exception to test encapsulation rule: This factory is defined globally for the same + * readability reasons as createTestAdapter. Each call creates a fresh capture context, + * maintaining test isolation. + */ +function createCapturingAdapter(): { + adapter: Adapter; + getCapturedStoreModule: () => AdapterStoreModule | null; +} { + let capturedStoreModule: AdapterStoreModule | null = null; + + function adapter(storeModule: AdapterStoreModule): TestNewAdapted; + function adapter( + storeModule: AdapterStoreModule, + resourceGetter: () => TestItem, + ): TestAdapted; + function adapter( + storeModule: AdapterStoreModule, + resourceGetter?: () => TestItem, + ): TestAdapted | TestNewAdapted { + capturedStoreModule = storeModule; + if (resourceGetter) { + const adapted = {} as TestAdapted; + const source = resourceGetter(); + + for (const key of Object.keys(source)) { + Object.defineProperty(adapted, key, { + get: () => resourceGetter()[key as keyof TestItem], + enumerable: true, + configurable: false, + }); + } + + Object.defineProperty(adapted, "mutable", { + value: ref({ ...resourceGetter() }) as Ref, + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "reset", { + value: vi.fn(), + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "update", { + value: vi.fn(), + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "patch", { + value: vi.fn(), + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "delete", { + value: vi.fn(), + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, "testMethod", { + value: () => `adapted-${resourceGetter().id}`, + enumerable: true, + configurable: false, + writable: false, + }); + + return adapted; + } + return { + name: "", + mutable: ref({ name: "" }) as Ref, + reset: vi.fn(), + create: vi.fn(), + testMethod: () => "new-adapted", + } as unknown as TestNewAdapted; + } + + return { + adapter: adapter as Adapter, + getCapturedStoreModule: () => capturedStoreModule, + }; +} + +describe("createAdapterStoreModule", () => { + describe("getAll", () => { + it("should return computed with empty array when no items", () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + + // Act + const store = createAdapterStoreModule(config); + + // Assert + expect(store.getAll.value).toEqual([]); + }); + + it("should return computed with all adapted items", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + { + id: 2, + name: "Item 2", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + + // Act + await store.retrieveAll(); + + // Assert + expect(store.getAll.value).toHaveLength(2); + expect(store.getAll.value[0]?.testMethod()).toBe("adapted-1"); + expect(store.getAll.value[1]?.testMethod()).toBe("adapted-2"); + }); + + it("should update when items are added to state", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const store = createAdapterStoreModule(config); + expect(store.getAll.value).toHaveLength(0); + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + + // Act + await store.retrieveAll(); + + // Assert + expect(store.getAll.value).toHaveLength(1); + }); + }); + + describe("getById", () => { + it("should return computed with undefined for non-existent id", () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + + // Act + const store = createAdapterStoreModule(config); + + // Assert + expect(store.getById(999).value).toBeUndefined(); + }); + + it("should return computed with adapted item for existing id", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + + // Act + const result = store.getById(1); + + // Assert + expect(result.value).toBeDefined(); + expect(result.value?.testMethod()).toBe("adapted-1"); + }); + + it("should update when item is modified", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const store = createAdapterStoreModule(config); + vi.mocked(httpService.getRequest).mockResolvedValue({ + data: [ + { + id: 1, + name: "Original", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ], + } as AxiosResponse); + await store.retrieveAll(); + const computed = store.getById(1); + expect(computed.value?.name).toBe("Original"); + vi.mocked(httpService.getRequest).mockResolvedValue({ + data: [ + { + id: 1, + name: "Updated", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ], + } as AxiosResponse); + + // Act + await store.retrieveAll(); + + // Assert + expect(computed.value?.name).toBe("Updated"); + }); + }); + + describe("getOrFailById", () => { + it("should wait for loading to finish before checking", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const store = createAdapterStoreModule(config); + + // Act + try { + await store.getOrFailById(1); + } catch { + // Expected to throw + } + + // Assert + expect(loadingService.ensureLoadingFinished).toHaveBeenCalled(); + }); + + it("should return adapted item when found", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + + // Act + const result = await store.getOrFailById(1); + + // Assert + expect(result.testMethod()).toBe("adapted-1"); + }); + + it("should throw EntryNotFoundError when item not found", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const store = createAdapterStoreModule(config); + + // Act & Assert + await expect(store.getOrFailById(999)).rejects.toThrow(EntryNotFoundError); + await expect(store.getOrFailById(999)).rejects.toThrow("test-items with id 999 not found"); + }); + }); + + describe("generateNew", () => { + it("should return new adapted resource from adapter", () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const store = createAdapterStoreModule(config); + + // Act + const result = store.generateNew(); + + // Assert + expect(result.testMethod()).toBe("new-adapted"); + }); + }); + + describe("retrieveAll", () => { + it("should call httpService.getRequest with domainName", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + vi.mocked(httpService.getRequest).mockResolvedValue({ + data: [] as TestItem[], + } as AxiosResponse); + const store = createAdapterStoreModule(config); + + // Act + await store.retrieveAll(); + + // Assert + expect(httpService.getRequest).toHaveBeenCalledWith("test-items"); + }); + + it("should store items in state as-is from response", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + { + id: 2, + name: "Item 2", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + + // Act + await store.retrieveAll(); + + // Assert + expect(store.getAll.value).toHaveLength(2); + }); + + it("should persist to storage service", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + + // Act + await store.retrieveAll(); + + // Assert + expect(storageService.put).toHaveBeenCalledWith("test-items", expect.any(Object)); + }); + }); + + describe("localStorage persistence", () => { + it("should initialize state from storage", () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storedItems = { + 1: { + id: 1, + name: "Stored Item", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + }; + const storageService: TestStorageService = { + put: vi.fn(), + get: vi.fn().mockReturnValue(storedItems), + }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + + // Act + const store = createAdapterStoreModule(config); + + // Assert + expect(storageService.get).toHaveBeenCalledWith("test-items", {}); + expect(store.getById(1).value).toBeDefined(); + }); + + it("should persist state changes to storage on retrieveAll", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + + // Act + await store.retrieveAll(); + + // Assert + expect(storageService.put).toHaveBeenCalledWith( + "test-items", + expect.objectContaining({ 1: expect.any(Object) as unknown }), + ); + }); + }); + + describe("memoization", () => { + it("should return the same adapted object reference when state has not changed", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + + // Act + const firstAccess = store.getAll.value[0]; + const secondAccess = store.getAll.value[0]; + + // Assert + expect(firstAccess).toBe(secondAccess); + }); + + it("should return the same adapted object after setById, with reactive properties reflecting the update", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const { adapter, getCapturedStoreModule } = createCapturingAdapter(); + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + const beforeUpdate = store.getById(1).value; + + // Act + const storeModule = getCapturedStoreModule() as unknown as AdapterStoreModule; + storeModule.setById({ + id: 1, + name: "Updated", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + }); + const afterUpdate = store.getById(1).value; + + // Assert — same adapted object reference (reactive getters, no cache invalidation) + expect(beforeUpdate).toBe(afterUpdate); + // Display properties reflect the updated store data via getter + expect(afterUpdate?.name).toBe("Updated"); + }); + + it("should clear adapted cache on deleteById", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const { adapter, getCapturedStoreModule } = createCapturingAdapter(); + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + + // Access to populate cache and capture storeModule + expect(store.getById(1).value).toBeDefined(); + + // Act + const storeModule = getCapturedStoreModule() as unknown as AdapterStoreModule; + storeModule.deleteById(1); + + // Assert + expect(store.getById(1).value).toBeUndefined(); + }); + + it("should clear all caches on retrieveAll", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + const beforeRetrieve = store.getAll.value[0]; + + // Act + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + await store.retrieveAll(); + const afterRetrieve = store.getAll.value[0]; + + // Assert — new frozen references, so adapted objects must be new + expect(beforeRetrieve).not.toBe(afterRetrieve); + }); + + it("should return the same computed ref for the same id across multiple getById calls", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + + // Act + const firstRef = store.getById(1); + const secondRef = store.getById(1); + + // Assert + expect(firstRef).toBe(secondRef); + }); + + it("should return different computed refs for different ids", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + { + id: 2, + name: "Item 2", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + + // Act + const ref1 = store.getById(1); + const ref2 = store.getById(2); + + // Assert + expect(ref1).not.toBe(ref2); + }); + + it("should create a new computed ref for same id after retrieveAll clears cache", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + const refBefore = store.getById(1); + + // Act + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + await store.retrieveAll(); + const refAfter = store.getById(1); + + // Assert + expect(refBefore).not.toBe(refAfter); + }); + + it("should create a new computed ref for same id after deleteById clears cache", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const { adapter, getCapturedStoreModule } = createCapturingAdapter(); + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + const refBefore = store.getById(1); + expect(refBefore.value).toBeDefined(); + + // Act + const storeModule = getCapturedStoreModule() as unknown as AdapterStoreModule; + storeModule.deleteById(1); + const refAfter = store.getById(1); + + // Assert + expect(refBefore).not.toBe(refAfter); + }); + + it("should return cached adapted object via getById when state has not changed", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter: createTestAdapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + + // Act + const firstValue = store.getById(1).value; + const secondValue = store.getById(1).value; + + // Assert + expect(firstValue).toBe(secondValue); + }); + }); + + describe("storeModule methods", () => { + it("should update state and persist when setById is called via adapter", () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const { adapter, getCapturedStoreModule } = createCapturingAdapter(); + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter, + httpService, + storageService, + loadingService, + }; + const store = createAdapterStoreModule(config); + store.generateNew(); + const newItem: TestItem = { + id: 1, + name: "New Item", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + + // Act + const capturedStoreModule = getCapturedStoreModule(); + expect(capturedStoreModule).not.toBeNull(); + const storeModule = capturedStoreModule as unknown as AdapterStoreModule; + storeModule.setById(newItem); + + // Assert + expect(store.getById(1).value).toBeDefined(); + expect(storageService.put).toHaveBeenCalledWith("test-items", expect.any(Object)); + }); + + it("should remove from state and persist when deleteById is called via adapter", async () => { + // Arrange + const httpService: Pick = { getRequest: vi.fn() }; + const storageService: TestStorageService = { put: vi.fn(), get: vi.fn().mockReturnValue({}) }; + const loadingService: TestLoadingService = { + ensureLoadingFinished: vi.fn().mockResolvedValue(undefined), + }; + const { adapter, getCapturedStoreModule } = createCapturingAdapter(); + const config: AdapterStoreConfig = { + domainName: "test-items", + adapter, + httpService, + storageService, + loadingService, + }; + const items: TestItem[] = [ + { + id: 1, + name: "Item 1", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(httpService.getRequest).mockResolvedValue({ data: items } as AxiosResponse< + TestItem[] + >); + const store = createAdapterStoreModule(config); + await store.retrieveAll(); + expect(store.getById(1).value).toBeDefined(); + vi.mocked(storageService.put).mockClear(); + + // Act + const capturedStoreModule = getCapturedStoreModule(); + expect(capturedStoreModule).not.toBeNull(); + const storeModule = capturedStoreModule as unknown as AdapterStoreModule; + storeModule.deleteById(1); + + // Assert + expect(store.getById(1).value).toBeUndefined(); + expect(storageService.put).toHaveBeenCalledWith("test-items", expect.any(Object)); + }); + }); +}); diff --git a/packages/adapter-store/tests/errors.spec.ts b/packages/adapter-store/tests/errors.spec.ts new file mode 100644 index 0000000..fedd015 --- /dev/null +++ b/packages/adapter-store/tests/errors.spec.ts @@ -0,0 +1,42 @@ +import { EntryNotFoundError, MissingResponseDataError } from "../src/errors"; +import { describe, expect, it } from "vitest"; + +describe("EntryNotFoundError", () => { + it("should create error with correct message", () => { + // Act + const error = new EntryNotFoundError("users", 42); + + // Assert + expect(error.message).toBe("users with id 42 not found"); + expect(error.name).toBe("EntryNotFoundError"); + }); + + it("should be an instance of Error", () => { + // Act + const error = new EntryNotFoundError("items", 1); + + // Assert + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(EntryNotFoundError); + }); +}); + +describe("MissingResponseDataError", () => { + it("should create error with correct message", () => { + // Act + const error = new MissingResponseDataError("No data returned"); + + // Assert + expect(error.message).toBe("No data returned"); + expect(error.name).toBe("MissingResponseDataError"); + }); + + it("should be an instance of Error", () => { + // Act + const error = new MissingResponseDataError("test"); + + // Assert + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(MissingResponseDataError); + }); +}); diff --git a/packages/adapter-store/tests/resource-adapter.spec.ts b/packages/adapter-store/tests/resource-adapter.spec.ts new file mode 100644 index 0000000..c068d5d --- /dev/null +++ b/packages/adapter-store/tests/resource-adapter.spec.ts @@ -0,0 +1,1160 @@ +import type { Adapted, AdapterStoreModule, Item, NewAdapted } from "../src/types"; + +import { MissingResponseDataError } from "../src/errors"; +import { resourceAdapter } from "../src/resource-adapter"; +import { describe, expect, it, vi } from "vitest"; +import { isRef, ref } from "vue"; + +interface TestItem extends Item { + id: number; + userName: string; + createdAt: string; +} + +type TestNew = Omit; + +describe("resource adapter", () => { + describe("adapting existing resource", () => { + const existingResource: TestItem = { id: 1, userName: "testUser", createdAt: "2024-01-01" }; + + it("should return the original resource properties", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Assert + expect(adapted.id).toBe(1); + expect(adapted.userName).toBe("testUser"); + expect(adapted.createdAt).toBe("2024-01-01"); + }); + + it("should provide a mutable ref with a deep copy of the resource", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Assert + expect(isRef(adapted.mutable)).toBe(true); + expect(adapted.mutable.value).toEqual({ + id: 1, + userName: "testUser", + createdAt: "2024-01-01", + }); + expect(adapted.mutable.value).not.toBe(existingResource); + }); + + it("should allow modifying the mutable ref without affecting original", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act + adapted.mutable.value.userName = "modifiedUser"; + + // Assert + expect(adapted.mutable.value.userName).toBe("modifiedUser"); + expect(adapted.userName).toBe("testUser"); + expect(existingResource.userName).toBe("testUser"); + }); + + it("should reset mutable state to original with reset()", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + adapted.mutable.value.userName = "modifiedUser"; + + // Act + adapted.reset(); + + // Assert + expect(adapted.mutable.value.userName).toBe("testUser"); + }); + + it("should call httpService.putRequest with data as-is on update()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const putRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "updatedUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest: vi.fn(), + putRequest, + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + adapted.mutable.value.userName = "updatedUser"; + + // Act + await adapted.update(); + + // Assert + expect(putRequest).toHaveBeenCalledWith("users/1", { + id: 1, + userName: "updatedUser", + createdAt: "2024-01-01", + }); + }); + + it("should call setById with response data after update()", async () => { + // Arrange + const setById = vi.fn(); + const storeModule: AdapterStoreModule = { setById, deleteById: vi.fn() }; + const putRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "updatedUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest: vi.fn(), + putRequest, + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.update(); + + // Assert + expect(setById).toHaveBeenCalledWith({ + id: 1, + userName: "updatedUser", + createdAt: "2024-01-01", + }); + }); + + it("should return the updated item from update()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const putRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "updatedUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest: vi.fn(), + putRequest, + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act + const result = await adapted.update(); + + // Assert + expect(result).toEqual({ id: 1, userName: "updatedUser", createdAt: "2024-01-01" }); + }); + + it("should throw MissingResponseDataError when update response has no data", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const putRequest = vi.fn().mockResolvedValue({ data: undefined }); + const httpService = { + postRequest: vi.fn(), + putRequest, + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert + await expect(adapted.update()).rejects.toThrow(MissingResponseDataError); + await expect(adapted.update()).rejects.toThrow( + "update route for users returned no model in response to put in store.", + ); + }); + + it("should call httpService.patchRequest with data as-is on patch()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const patchRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "patchedUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest, + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.patch({ userName: "patchedUser" }); + + // Assert + expect(patchRequest).toHaveBeenCalledWith("users/1", { userName: "patchedUser" }); + }); + + it("should call setById with response data after patch()", async () => { + // Arrange + const setById = vi.fn(); + const storeModule: AdapterStoreModule = { setById, deleteById: vi.fn() }; + const patchRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "patchedUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest, + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.patch({ userName: "patchedUser" }); + + // Assert + expect(setById).toHaveBeenCalledWith({ + id: 1, + userName: "patchedUser", + createdAt: "2024-01-01", + }); + }); + + it("should return the patched item from patch()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const patchRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "patchedUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest, + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act + const result = await adapted.patch({ userName: "patchedUser" }); + + // Assert + expect(result).toEqual({ id: 1, userName: "patchedUser", createdAt: "2024-01-01" }); + }); + + it("should throw MissingResponseDataError when patch response has no data", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const patchRequest = vi.fn().mockResolvedValue({ data: undefined }); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest, + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert + await expect(adapted.patch({ userName: "patchedUser" })).rejects.toThrow( + MissingResponseDataError, + ); + await expect(adapted.patch({ userName: "patchedUser" })).rejects.toThrow( + "patch route for users returned no model in response to put in store.", + ); + }); + + it("should propagate HTTP errors from patch()", async () => { + // Arrange + const setById = vi.fn(); + const storeModule: AdapterStoreModule = { setById, deleteById: vi.fn() }; + const patchRequest = vi.fn().mockRejectedValue(new Error("Network error")); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest, + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert + await expect(adapted.patch({ userName: "patchedUser" })).rejects.toThrow("Network error"); + expect(setById).not.toHaveBeenCalled(); + }); + + it("should call httpService.deleteRequest on delete()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const deleteRequest = vi.fn().mockResolvedValue({}); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest, + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.delete(); + + // Assert + expect(deleteRequest).toHaveBeenCalledWith("users/1"); + }); + + it("should call deleteById after delete()", async () => { + // Arrange + const deleteById = vi.fn(); + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById }; + const deleteRequest = vi.fn().mockResolvedValue({}); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest, + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.delete(); + + // Assert + expect(deleteById).toHaveBeenCalledWith(1); + }); + + it("should propagate HTTP errors from update()", async () => { + // Arrange + const setById = vi.fn(); + const storeModule: AdapterStoreModule = { setById, deleteById: vi.fn() }; + const putRequest = vi.fn().mockRejectedValue(new Error("Network error")); + const httpService = { + postRequest: vi.fn(), + putRequest, + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert + await expect(adapted.update()).rejects.toThrow("Network error"); + expect(setById).not.toHaveBeenCalled(); + }); + + it("should propagate HTTP errors from delete()", async () => { + // Arrange + const deleteById = vi.fn(); + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById }; + const deleteRequest = vi.fn().mockRejectedValue(new Error("Network error")); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest, + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert + await expect(adapted.delete()).rejects.toThrow("Network error"); + expect(deleteById).not.toHaveBeenCalled(); + }); + + it("should have update, patch, and delete methods", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Assert + expect(adapted).toHaveProperty("update"); + expect(adapted).toHaveProperty("patch"); + expect(adapted).toHaveProperty("delete"); + expect(typeof adapted.update).toBe("function"); + expect(typeof adapted.patch).toBe("function"); + expect(typeof adapted.delete).toBe("function"); + }); + + it("should not have create method", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Assert + expect(adapted).not.toHaveProperty("create"); + }); + + it("should have reactive display properties that reflect getter changes", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + let source: TestItem = { id: 1, userName: "original", createdAt: "2024-01-01" }; + const adapted: Adapted = resourceAdapter( + () => source, + "users", + storeModule, + httpService, + ); + expect(adapted.userName).toBe("original"); + + // Act + source = { id: 1, userName: "updated", createdAt: "2024-01-01" }; + + // Assert + expect(adapted.userName).toBe("updated"); + }); + + it("should have read-only display properties", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert — writing to a getter-only property throws in strict mode + expect(() => { + (adapted as unknown as Record).userName = "new"; + }).toThrow(); + }); + + it("should reset mutable to current getter state, not original state", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + let source: TestItem = { id: 1, userName: "original", createdAt: "2024-01-01" }; + const adapted: Adapted = resourceAdapter( + () => source, + "users", + storeModule, + httpService, + ); + adapted.mutable.value.userName = "dirty"; + + // Act — simulate store update, then reset + source = { id: 1, userName: "serverUpdated", createdAt: "2024-01-01" }; + adapted.reset(); + + // Assert — mutable reflects the current server state, not the original + expect(adapted.mutable.value.userName).toBe("serverUpdated"); + }); + + it("should read current id from getter for update()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const putRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "updatedUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest: vi.fn(), + putRequest, + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const source: TestItem = { id: 1, userName: "testUser", createdAt: "2024-01-01" }; + const adapted: Adapted = resourceAdapter( + () => source, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.update(); + + // Assert — uses id from getter + expect(putRequest).toHaveBeenCalledWith("users/1", expect.any(Object)); + }); + + it("should read current id from getter for delete()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const deleteRequest = vi.fn().mockResolvedValue({}); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest, + }; + const source: TestItem = { id: 1, userName: "testUser", createdAt: "2024-01-01" }; + const adapted: Adapted = resourceAdapter( + () => source, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.delete(); + + // Assert — uses id from getter + expect(deleteRequest).toHaveBeenCalledWith("users/1"); + }); + + it("should read current id from getter for patch()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const patchRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "patchedUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest, + deleteRequest: vi.fn(), + }; + const source: TestItem = { id: 1, userName: "testUser", createdAt: "2024-01-01" }; + const adapted: Adapted = resourceAdapter( + () => source, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.patch({ userName: "patchedUser" }); + + // Assert — uses id from getter + expect(patchRequest).toHaveBeenCalledWith("users/1", { userName: "patchedUser" }); + }); + + it("should allow storing in a Vue ref() and accessing .mutable without TypeError", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act — wrapping in ref() creates a Vue Proxy; accessing .mutable triggers the get trap + const wrapped = ref(adapted); + + // Assert — no TypeError from Proxy invariant violation + // Vue auto-unwraps the inner Ref, so wrapped.value.mutable is the unwrapped value + expect(wrapped.value.mutable).toEqual({ + id: 1, + userName: "testUser", + createdAt: "2024-01-01", + }); + }); + + it("should still prevent direct assignment to mutable (writable: false protects)", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: Adapted = resourceAdapter( + () => existingResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert — writable: false still prevents reassignment + expect(() => { + (adapted as unknown as Record).mutable = "overwritten"; + }).toThrow(TypeError); + }); + }); + + describe("property descriptors on existing resource adapter", () => { + const existingRes: TestItem = { id: 1, userName: "testUser", createdAt: "2024-01-01" }; + + it("should have enumerable resource properties", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: Adapted = resourceAdapter( + () => existingRes, + "users", + storeModule, + httpService, + ); + + // Assert — resource properties, mutable, reset, update, patch, delete should all be enumerable + const keys = Object.keys(adapted); + expect(keys).toContain("id"); + expect(keys).toContain("userName"); + expect(keys).toContain("createdAt"); + expect(keys).toContain("mutable"); + expect(keys).toContain("reset"); + expect(keys).toContain("update"); + expect(keys).toContain("patch"); + expect(keys).toContain("delete"); + }); + + it("should have configurable resource properties to support Vue reactivity", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: Adapted = resourceAdapter( + () => existingRes, + "users", + storeModule, + httpService, + ); + + // Assert — resource getter properties must be configurable for Vue proxy compatibility + const idDescriptor = Object.getOwnPropertyDescriptor(adapted, "id"); + expect(idDescriptor?.configurable).toBe(true); + + // mutable, reset, update, patch, delete must also be configurable + const mutableDescriptor = Object.getOwnPropertyDescriptor(adapted, "mutable"); + expect(mutableDescriptor?.configurable).toBe(true); + const resetDescriptor = Object.getOwnPropertyDescriptor(adapted, "reset"); + expect(resetDescriptor?.configurable).toBe(true); + const updateDescriptor = Object.getOwnPropertyDescriptor(adapted, "update"); + expect(updateDescriptor?.configurable).toBe(true); + const patchDescriptor = Object.getOwnPropertyDescriptor(adapted, "patch"); + expect(patchDescriptor?.configurable).toBe(true); + const deleteDescriptor = Object.getOwnPropertyDescriptor(adapted, "delete"); + expect(deleteDescriptor?.configurable).toBe(true); + }); + + it("should have non-writable method properties (mutable, reset, update, patch, delete)", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: Adapted = resourceAdapter( + () => existingRes, + "users", + storeModule, + httpService, + ); + + // Assert — all method/ref properties are writable: false + const mutableDescriptor = Object.getOwnPropertyDescriptor(adapted, "mutable"); + expect(mutableDescriptor?.writable).toBe(false); + const resetDescriptor = Object.getOwnPropertyDescriptor(adapted, "reset"); + expect(resetDescriptor?.writable).toBe(false); + const updateDescriptor = Object.getOwnPropertyDescriptor(adapted, "update"); + expect(updateDescriptor?.writable).toBe(false); + const patchDescriptor = Object.getOwnPropertyDescriptor(adapted, "patch"); + expect(patchDescriptor?.writable).toBe(false); + const deleteDescriptor = Object.getOwnPropertyDescriptor(adapted, "delete"); + expect(deleteDescriptor?.writable).toBe(false); + }); + }); + + describe("adapting new resource", () => { + const newResource: TestNew = { userName: "newUser", createdAt: "2024-01-01" }; + + it("should return the original resource properties", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Assert + expect(adapted.userName).toBe("newUser"); + }); + + it("should provide a mutable ref with a deep copy of the resource", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Assert + expect(isRef(adapted.mutable)).toBe(true); + expect(adapted.mutable.value).toEqual({ userName: "newUser", createdAt: "2024-01-01" }); + expect(adapted.mutable.value).not.toBe(newResource); + }); + + it("should allow modifying the mutable ref without affecting original", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Act + adapted.mutable.value.userName = "modifiedUser"; + + // Assert + expect(adapted.mutable.value.userName).toBe("modifiedUser"); + expect(adapted.userName).toBe("newUser"); + }); + + it("should reset mutable state to original with reset()", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + adapted.mutable.value.userName = "modifiedUser"; + + // Act + adapted.reset(); + + // Assert + expect(adapted.mutable.value.userName).toBe("newUser"); + }); + + it("should call httpService.postRequest with data as-is on create()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const postRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "newUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest, + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.create(); + + // Assert + expect(postRequest).toHaveBeenCalledWith("users", { + userName: "newUser", + createdAt: "2024-01-01", + }); + }); + + it("should call setById with response data after create()", async () => { + // Arrange + const setById = vi.fn(); + const storeModule: AdapterStoreModule = { setById, deleteById: vi.fn() }; + const postRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "newUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest, + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Act + await adapted.create(); + + // Assert + expect(setById).toHaveBeenCalledWith({ id: 1, userName: "newUser", createdAt: "2024-01-01" }); + }); + + it("should return the created item from create()", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const postRequest = vi + .fn() + .mockResolvedValue({ data: { id: 1, userName: "newUser", createdAt: "2024-01-01" } }); + const httpService = { + postRequest, + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Act + const result = await adapted.create(); + + // Assert + expect(result).toEqual({ id: 1, userName: "newUser", createdAt: "2024-01-01" }); + }); + + it("should throw MissingResponseDataError when create response has no data", async () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const postRequest = vi.fn().mockResolvedValue({ data: undefined }); + const httpService = { + postRequest, + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert + await expect(adapted.create()).rejects.toThrow(MissingResponseDataError); + await expect(adapted.create()).rejects.toThrow( + "create route for users returned no model in response to put in store.", + ); + }); + + it("should propagate HTTP errors from create()", async () => { + // Arrange + const setById = vi.fn(); + const storeModule: AdapterStoreModule = { setById, deleteById: vi.fn() }; + const postRequest = vi.fn().mockRejectedValue(new Error("Network error")); + const httpService = { + postRequest, + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Act & Assert + await expect(adapted.create()).rejects.toThrow("Network error"); + expect(setById).not.toHaveBeenCalled(); + }); + + it("should have create method", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Assert + expect(adapted).toHaveProperty("create"); + expect(typeof adapted.create).toBe("function"); + }); + + it("should not have update, patch, and delete methods", () => { + // Arrange + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted: NewAdapted = resourceAdapter( + newResource, + "users", + storeModule, + httpService, + ); + + // Assert + expect(adapted).not.toHaveProperty("update"); + expect(adapted).not.toHaveProperty("patch"); + expect(adapted).not.toHaveProperty("delete"); + }); + }); + + describe("deep copy behavior", () => { + it("should deeply copy nested objects in mutable", () => { + // Arrange + interface NestedItem extends Item { + id: number; + nested: { value: string }; + } + const nestedResource: NestedItem = { id: 1, nested: { value: "original" } }; + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted = resourceAdapter(() => nestedResource, "nested", storeModule, httpService); + adapted.mutable.value.nested.value = "modified"; + + // Assert + expect(adapted.mutable.value.nested.value).toBe("modified"); + expect(nestedResource.nested.value).toBe("original"); + }); + + it("should deeply copy arrays in mutable", () => { + // Arrange + interface ArrayItem extends Item { + id: number; + items: string[]; + } + const arrayResource: ArrayItem = { id: 1, items: ["a", "b"] }; + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted = resourceAdapter(() => arrayResource, "arrays", storeModule, httpService); + adapted.mutable.value.items.push("c"); + + // Assert + expect(adapted.mutable.value.items).toEqual(["a", "b", "c"]); + expect(arrayResource.items).toEqual(["a", "b"]); + }); + + it("should deeply copy Date objects in mutable", () => { + // Arrange + interface DateItem extends Item { + id: number; + createdAt: Date; + } + const originalDate = new Date("2024-01-01"); + const dateResource: DateItem = { id: 1, createdAt: originalDate }; + const storeModule: AdapterStoreModule = { setById: vi.fn(), deleteById: vi.fn() }; + const httpService = { + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + }; + + // Act + const adapted = resourceAdapter(() => dateResource, "dates", storeModule, httpService); + adapted.mutable.value.createdAt.setFullYear(2025); + + // Assert + expect(adapted.mutable.value.createdAt.getFullYear()).toBe(2025); + expect(originalDate.getFullYear()).toBe(2024); + }); + }); +}); diff --git a/packages/adapter-store/tsconfig.json b/packages/adapter-store/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/adapter-store/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapter-store/tsdown.config.ts b/packages/adapter-store/tsdown.config.ts new file mode 100644 index 0000000..aa2e8b8 --- /dev/null +++ b/packages/adapter-store/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/adapter-store/vitest.config.ts b/packages/adapter-store/vitest.config.ts new file mode 100644 index 0000000..da04ee7 --- /dev/null +++ b/packages/adapter-store/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "adapter-store", + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + }, + }, +});