diff --git a/packages/react-ghostmaker/src/context.test.ts b/packages/react-ghostmaker/src/context.test.ts new file mode 100644 index 0000000..d999188 --- /dev/null +++ b/packages/react-ghostmaker/src/context.test.ts @@ -0,0 +1,35 @@ +import type { QueryFunctionContext } from "@tanstack/react-query"; +import { expect, test } from "vitest"; +import { + forwardQueryContext, + getQueryContext, + ghostFnContext, +} from "./context.ts"; + +const queryContext = { + queryKey: ["react-ghostmaker", "Target"], +} as unknown as QueryFunctionContext; + +test("getQueryContext() returns undefined outside of a query", () => { + expect(getQueryContext()).toBeUndefined(); +}); + +test("getQueryContext() returns the context of the current query", () => { + const result = ghostFnContext.run({ query: queryContext }, () => + getQueryContext(), + ); + + expect(result).toBe(queryContext); +}); + +test("forwardQueryContext() returns the result of the function", () => { + expect(forwardQueryContext(() => "result")).toBe("result"); +}); + +test("forwardQueryContext() provides the query context to the function", () => { + const result = ghostFnContext.run({ query: queryContext }, () => + forwardQueryContext(() => getQueryContext()), + ); + + expect(result).toBe(queryContext); +}); diff --git a/packages/react-ghostmaker/src/hash.test.ts b/packages/react-ghostmaker/src/hash.test.ts new file mode 100644 index 0000000..56ab383 --- /dev/null +++ b/packages/react-ghostmaker/src/hash.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { hashObject } from "./hash.ts"; + +test("returns equal hashes for objects with equal content", () => { + expect(hashObject({ a: 1, b: [1, 2] })).toBe(hashObject({ a: 1, b: [1, 2] })); +}); + +test("returns different hashes for objects with different content", () => { + expect(hashObject({ a: 1 })).not.toBe(hashObject({ a: 2 })); +}); + +test("caches the hash per object instance", () => { + const obj = { a: 1 }; + const initialHash = hashObject(obj); + + obj.a = 2; + + expect(hashObject(obj)).toBe(initialHash); + expect(hashObject({ a: 2 })).not.toBe(initialHash); +}); diff --git a/packages/react-ghostmaker/src/invalidate.test.ts b/packages/react-ghostmaker/src/invalidate.test.ts new file mode 100644 index 0000000..0358754 --- /dev/null +++ b/packages/react-ghostmaker/src/invalidate.test.ts @@ -0,0 +1,40 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, expect, test } from "vitest"; +import { invalidateGhosts } from "./invalidate.ts"; + +const queryClient = new QueryClient(); + +const keys = { + root: ["react-ghostmaker"], + target: ["react-ghostmaker", "Target"], + chain: ["react-ghostmaker", "Target", "getA"], + dependent: ["react-ghostmaker", "Target", "getA", "getB"], + sibling: ["react-ghostmaker", "Target", "getC"], + other: ["react-ghostmaker", "Other"], +}; + +const isInvalidated = (key: readonly unknown[]) => + queryClient.getQueryState(key)?.isInvalidated; + +beforeEach(() => { + queryClient.clear(); + for (const key of Object.values(keys)) { + queryClient.setQueryData(key, "data"); + } +}); + +test("invalidates the ghost, its dependents and its ancestors", async () => { + await invalidateGhosts(queryClient, keys.chain); + + expect(isInvalidated(keys.root)).toBe(true); + expect(isInvalidated(keys.target)).toBe(true); + expect(isInvalidated(keys.chain)).toBe(true); + expect(isInvalidated(keys.dependent)).toBe(true); +}); + +test("does not invalidate siblings or other targets", async () => { + await invalidateGhosts(queryClient, keys.chain); + + expect(isInvalidated(keys.sibling)).toBe(false); + expect(isInvalidated(keys.other)).toBe(false); +}); diff --git a/packages/react-ghostmaker/src/makeGhost.test.tsx b/packages/react-ghostmaker/src/makeGhost.test.tsx index e206dc7..49a949d 100644 --- a/packages/react-ghostmaker/src/makeGhost.test.tsx +++ b/packages/react-ghostmaker/src/makeGhost.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render } from "@testing-library/react"; +import { act, cleanup, render } from "@testing-library/react"; import { describe, expect, test, vitest, beforeEach, afterEach } from "vitest"; import { CustomerDetailed, @@ -15,6 +15,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { invalidateGhosts } from "./invalidate.ts"; import { makeGhost } from "./makeGhost.ts"; import { cleanupTargetHashes } from "./useGhostChain.ts"; +import { Component, Suspense, type ReactNode } from "react"; + +class FailingService { + public async fail(): Promise { + throw new Error("Service failed"); + } +} beforeEach(() => { vitest.useFakeTimers(); @@ -110,6 +117,22 @@ describe("Await", () => { ]); expect(customerName).toBeUndefined(); }); + test("rejects when a method rejects", async () => { + await expect(makeGhost(new FailingService()).fail()).rejects.toThrow( + "Service failed", + ); + }); + + test("resolves property access on the target", async () => { + expect(await makeGhost({ name: "Plain" }).name).toBe("Plain"); + }); + + test("converts to a string without resolving", () => { + expect(String(ProjectGhost.ofId("Project A").getDetailed())).toBe( + "ReactGhostmakerFunction", + ); + expect(projectMocks.getDetailed).not.toHaveBeenCalled(); + }); }); describe("Hooks", () => { @@ -232,6 +255,201 @@ describe("Hooks", () => { expect(transform).toHaveBeenCalledWith(undefined); }); + test("generates query keys from the ghost chain", async () => { + await renderHookWithSuspense(() => + ProjectGhost.ofId("Project A") + .getDetailed() + .customer.getDetailed() + .getName() + .use(), + ); + + const projectKey = ["react-ghostmaker", "Project", "ofId", "Project A"]; + const queryKeys = queryClient + .getQueryCache() + .findAll() + .map((query) => query.queryKey); + + expect(queryKeys).toHaveLength(4); + expect(queryKeys).toEqual( + expect.arrayContaining([ + projectKey, + [...projectKey, "getDetailed"], + [...projectKey, "getDetailed", "customer", "getDetailed"], + [...projectKey, "getDetailed", "customer", "getDetailed", "getName"], + ]), + ); + }); + + test("passes query options to all queries of the chain", async () => { + await renderHookWithSuspense(() => + ProjectGhost.ofId("Project A") + .getDetailed() + .getName() + .use({ meta: { source: "test" } }), + ); + + const queries = queryClient.getQueryCache().findAll(); + + expect(queries).toHaveLength(3); + for (const query of queries) { + expect(query.meta).toEqual({ source: "test" }); + } + }); + + test("re-runs transform only when its dependencies change", async () => { + const transform = vitest.fn((name: string) => name); + let dependency = 1; + + const { rerender } = await renderHookWithSuspense(() => + ProjectGhost.ofId("Project A") + .getDetailed() + .name.transform(transform, [dependency]) + .use(), + ); + expect(transform).toHaveBeenCalledTimes(1); + + rerender(); + expect(transform).toHaveBeenCalledTimes(1); + + dependency = 2; + rerender(); + expect(transform).toHaveBeenCalledTimes(2); + }); + + test("throws errors to the next error boundary", async () => { + const consoleError = vitest + .spyOn(console, "error") + .mockImplementation(() => { + // silence React error logging + }); + + class ErrorBoundary extends Component< + { children: ReactNode }, + { error?: Error } + > { + public state: { error?: Error } = {}; + + public static getDerivedStateFromError(error: Error) { + return { error }; + } + + public render() { + return this.state.error + ? `Error: ${this.state.error.message}` + : this.props.children; + } + } + + const FailingComponent = () => ( + <>{makeGhost(new FailingService()).fail().use({ retry: false })} + ); + + const ui = render( + + + + + , + ); + + await Promise.all([ + ui.findByText("Error: Service failed"), + vitest.runOnlyPendingTimersAsync(), + ]); + + consoleError.mockRestore(); + }); + + describe("Render", () => { + async function renderWithSuspense(node: ReactNode) { + const ui = render( + + {node} + , + ); + expect(ui.container.textContent).toBe("loading"); + + // each query of the chain starts its sleep timer after the previous one + for (let i = 0; i < 3; i++) { + await act(() => vitest.runOnlyPendingTimersAsync()); + } + + return ui; + } + + test("ghost.render() renders the resolved value", async () => { + const ui = await renderWithSuspense( + ProjectGhost.ofId("Project A").getDetailed().name.render(), + ); + + expect(ui.container.textContent).toBe("Project Project A"); + }); + + test("ghost.render() renders the transformed value", async () => { + const ui = await renderWithSuspense( + ProjectGhost.ofId("Project A") + .getDetailed() + .render((project) =>

{project.name.toUpperCase()}

), + ); + + expect(ui.container.querySelector("h1")?.textContent).toBe( + "PROJECT PROJECT A", + ); + }); + + test("ghost.render() is lazy", () => { + ProjectGhost.ofId("Project A").getDetailed().name.render(); + expect(projectMocks.getDetailed).not.toHaveBeenCalled(); + }); + }); + + describe("Reset", () => { + test("useGhost.reset() triggers re-execution of all async methods", async () => { + const { result } = await renderHookWithSuspense(() => + ProjectGhost.ofId("Project A") + .getDetailed() + .customer.getDetailed() + .getName() + .useGhost(), + ); + expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1); + expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1); + expect(customerMocks.getName).toHaveBeenCalledTimes(1); + + result.current?.reset(); + await vitest.runOnlyPendingTimersAsync(); + + expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2); + expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2); + expect(customerMocks.getName).toHaveBeenCalledTimes(2); + }); + + test("ghost.reset() removes cached data of the ghost chain", async () => { + const ghost = ProjectGhost.ofId("Project A").getDetailed(); + const unrelatedGhost = CustomerGhost.ofId("Customer A"); + + await renderHookWithSuspense(() => [ghost.use(), unrelatedGhost.use()]); + + const projectKey = ["react-ghostmaker", "Project", "ofId", "Project A"]; + const customerKey = [ + "react-ghostmaker", + "Customer", + "ofId", + "Customer A", + ]; + cleanup(); + + await ghost.reset(queryClient); + + expect(queryClient.getQueryData(projectKey)).toBeUndefined(); + expect( + queryClient.getQueryData([...projectKey, "getDetailed"]), + ).toBeUndefined(); + expect(queryClient.getQueryData(customerKey)).toBeDefined(); + }); + }); + describe("Invalidation", () => { test("useGhost.invalidate() triggers re-execution of all async methods", async () => { const { result } = await renderHookWithSuspense(() => diff --git a/packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts b/packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts index c169375..0778222 100644 --- a/packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts +++ b/packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts @@ -1,4 +1,5 @@ -import { asGhostProps } from "../maybeGhost/index.ts"; +import { asGhost, asGhostProps } from "../maybeGhost/index.ts"; +import { makeGhost } from "../makeGhost.ts"; import { isReactGhost } from "../types.ts"; import { expect, test } from "vitest"; @@ -57,3 +58,27 @@ test("does not make Ghosts of excluded props", () => { expect(ghostProps.secondGhost).not.toSatisfy(isReactGhost); expect(ghostProps.thirdGhost).not.toSatisfy(isReactGhost); }); + +test("asGhost() returns ghosts as they are", () => { + const ghost = makeGhost({ value: "hello" }); + + expect(asGhost(ghost)).toBe(ghost); +}); + +test("asGhost() makes a ghost of values", async () => { + const ghost = asGhost({ value: "hello" }); + + expect(ghost).toSatisfy(isReactGhost); + expect(await ghost.value).toBe("hello"); +}); + +test("asGhostProps() keeps ghost props as they are", () => { + const ghost = makeGhost("hello"); + + const ghostProps = asGhostProps({ + first: ghost as unknown as string, + third: "!", + }); + + expect(ghostProps.firstGhost).toBe(ghost); +}); diff --git a/packages/react-ghostmaker/src/metaData.test.ts b/packages/react-ghostmaker/src/metaData.test.ts new file mode 100644 index 0000000..93e3279 --- /dev/null +++ b/packages/react-ghostmaker/src/metaData.test.ts @@ -0,0 +1,199 @@ +import { + getMetaData, + getModelId, + getModelName, + GhostMakerModel, + ghostMakerModel, +} from "./metaData.ts"; +import type * as MetaDataModule from "./metaData.ts"; +import { afterEach, describe, expect, test, vitest } from "vitest"; + +@GhostMakerModel({ + name: "TheGrandParent", + getId: (grandParent) => grandParent.id, +}) +class GrandParent { + public readonly id: string; + + public constructor(id: string) { + this.id = id; + } +} + +@GhostMakerModel({ + name: "TheParent", +}) +class Parent extends GrandParent {} + +@GhostMakerModel({ + name: "TheChild", +}) +class Child extends Parent {} + +@GhostMakerModel({ + name: "OnlyName", +}) +class OnlyName {} + +@GhostMakerModel({ + name: "OverridingId", + getId: (model) => `overridden-${model.id}`, +}) +class OverridingId extends GrandParent {} + +class Undecorated {} + +class UndecoratedChild extends Parent {} + +afterEach(() => { + vitest.restoreAllMocks(); +}); + +test("getMetaData returns correct names", () => { + expect(getMetaData(new Child("id"))?.name).toBe("TheChild"); + expect(getMetaData(new Parent("id"))?.name).toBe("TheParent"); + expect(getMetaData(Parent)?.name).toBe("TheParent"); + expect(getMetaData(Child)?.name).toBe("TheChild"); +}); + +test("getMetaData returns getId function", () => { + expect(getMetaData(new Child("id"))?.getId).toBeDefined(); + expect(getMetaData(new Parent("id"))?.getId).toBeDefined(); + expect(getMetaData(Parent)?.getId).toBeDefined(); + expect(getMetaData(Child)?.getId).toBeDefined(); +}); + +describe("getModelName()", () => { + test("returns the name of a decorated model", () => { + expect(getModelName(new GrandParent("id"))).toBe("TheGrandParent"); + expect(getModelName(GrandParent)).toBe("TheGrandParent"); + }); + + test("returns the name of a model without getId", () => { + expect(getModelName(new OnlyName())).toBe("OnlyName"); + }); + + test("returns the nearest name of an undecorated subclass", () => { + expect(getModelName(new UndecoratedChild("id"))).toBe("TheParent"); + }); + + test("returns undefined for undecorated values", () => { + expect(getModelName(new Undecorated())).toBeUndefined(); + expect(getModelName(Undecorated)).toBeUndefined(); + expect(getModelName({})).toBeUndefined(); + expect(getModelName("string")).toBeUndefined(); + expect(getModelName(42)).toBeUndefined(); + expect(getModelName(null)).toBeUndefined(); + expect(getModelName(undefined)).toBeUndefined(); + }); +}); + +describe("getModelId()", () => { + test("returns the id of a decorated model", () => { + expect(getModelId(new GrandParent("gp"))).toBe("gp"); + }); + + test("uses getId inherited from a parent class", () => { + expect(getModelId(new Child("child"))).toBe("child"); + expect(getModelId(new UndecoratedChild("undecorated"))).toBe("undecorated"); + }); + + test("prefers getId of the subclass over the parent class", () => { + expect(getModelId(new OverridingId("id"))).toBe("overridden-id"); + }); + + test("returns undefined for models without getId", () => { + expect(getModelId(new OnlyName())).toBeUndefined(); + }); + + test("returns undefined for undecorated values", () => { + expect(getModelId(new Undecorated())).toBeUndefined(); + expect(getModelId({ id: "id" })).toBeUndefined(); + expect(getModelId("string")).toBeUndefined(); + expect(getModelId(undefined)).toBeUndefined(); + }); +}); + +describe("ghostMakerModel() (dynamic models)", () => { + interface DynamicThing { + kind: "dynamic-thing"; + key: string; + } + + const isDynamicThing = (something: unknown): something is DynamicThing => + typeof something === "object" && + something !== null && + "kind" in something && + something.kind === "dynamic-thing"; + + ghostMakerModel((something) => { + if (isDynamicThing(something)) { + return { + name: "DynamicThing", + getId: (thing) => (thing as unknown as DynamicThing).key, + }; + } + }); + + ghostMakerModel((something) => { + if (something instanceof GrandParent) { + return { name: "NeverUsedForDecoratedModels" }; + } + }); + + test("provides meta data for plain objects", () => { + const thing: DynamicThing = { kind: "dynamic-thing", key: "k1" }; + + expect(getModelName(thing)).toBe("DynamicThing"); + expect(getModelId(thing)).toBe("k1"); + }); + + test("is not used when the value does not match", () => { + expect(getModelName({ kind: "other" })).toBeUndefined(); + }); + + test("decorator meta data takes precedence over dynamic models", () => { + expect(getModelName(new GrandParent("id"))).toBe("TheGrandParent"); + }); +}); + +describe("name conflicts", () => { + test("warns in development when a name is registered twice", () => { + const warn = vitest.spyOn(console, "warn").mockImplementation(() => { + // silence + }); + + @GhostMakerModel({ name: "Conflicting" }) + class First {} + + expect(warn).not.toHaveBeenCalled(); + + @GhostMakerModel({ name: "Conflicting" }) + class Second {} + + expect(warn).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining('"Conflicting" is already registered'), + ); + expect(getModelName(new First())).toBe("Conflicting"); + expect(getModelName(new Second())).toBe("Conflicting"); + }); + + test("throws in production when a name is registered twice", async () => { + vitest.stubEnv("NODE_ENV", "production"); + vitest.resetModules(); + + try { + const { GhostMakerModel: ProdGhostMakerModel } = + await vitest.importActual("./metaData.ts"); + + const decorate = ProdGhostMakerModel({ name: "ProdConflicting" }); + decorate(class First {}); + + expect(() => decorate(class Second {})).toThrow( + '"ProdConflicting" is already registered', + ); + } finally { + vitest.unstubAllEnvs(); + } + }); +}); diff --git a/packages/react-ghostmaker/src/metaData.ts b/packages/react-ghostmaker/src/metaData.ts index 5b7f617..5cb0ce0 100644 --- a/packages/react-ghostmaker/src/metaData.ts +++ b/packages/react-ghostmaker/src/metaData.ts @@ -61,7 +61,7 @@ export const getMetaData = ( something: unknown, ): GhostMakerModelMeta | undefined => { return ( - getMetaDataRecursive(something, undefined, getClass(something)) ?? + getMetaDataRecursive(getClass(something), undefined) ?? getDynamicMetaData(something) ); }; @@ -79,35 +79,33 @@ export const getModelId = (something: unknown): string | undefined => { } }; +const isCompleteMetaData = ( + meta: GhostMakerModelMeta | undefined, +) => !!meta && "getId" in meta && "name" in meta; + +/** + * Collects meta data depth-first along the prototype chain(s). Meta data of the + * class itself wins over its parents, earlier parents (including their + * ancestors) win over later ones. + */ const getMetaDataRecursive = ( - something: unknown, - collectedMeta?: Partial>, - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - klass?: Function, + klass: unknown, + collectedMeta: GhostMakerModelMeta | undefined, ): GhostMakerModelMeta | undefined => { - const currentClass = getClass(something); + if (isCompleteMetaData(collectedMeta) || is.nullOrUndefined(klass)) { + return collectedMeta; + } - const meta = store.get(klass ?? currentClass); + const meta = store.get(klass); - const mergedMeta = + let mergedMeta = meta || collectedMeta ? { ...meta, ...collectedMeta } : undefined; - if (mergedMeta && "getId" in mergedMeta && "name" in mergedMeta) { - return mergedMeta; + for (const proto of getProtoypes.current(klass)) { + mergedMeta = getMetaDataRecursive(proto, mergedMeta); } - if (!klass) { - return mergedMeta; - } - - const prototypes = getProtoypes.current(klass); - - for (const proto of prototypes) { - const result = getMetaDataRecursive(something, mergedMeta, proto); - if (result) { - return result; - } - } + return mergedMeta; }; function isClass(value: unknown): value is Class { diff --git a/packages/react-ghostmaker/src/polytype.test.ts b/packages/react-ghostmaker/src/polytype.test.ts new file mode 100644 index 0000000..a2bac82 --- /dev/null +++ b/packages/react-ghostmaker/src/polytype.test.ts @@ -0,0 +1,115 @@ +import { classes } from "polytype"; +import { expect, test } from "vitest"; +import { GhostMakerModel } from "./polytype.ts"; +import { getModelId, getModelName } from "./metaData.ts"; + +@GhostMakerModel({ + name: "Named", +}) +class Named {} + +@GhostMakerModel({ + name: "Identified", + getId: (model) => model.id, +}) +class Identified { + public readonly id: string; + + public constructor(id: string) { + this.id = id; + } +} + +@GhostMakerModel({ + getId: (model) => `id-only-${model.id}`, +}) +class IdOnly { + public readonly id: string; + + public constructor(id: string) { + this.id = id; + } +} + +@GhostMakerModel({ + name: "NamedBase", +}) +class NamedBase {} + +class NamedSub extends NamedBase {} + +class MergedParents extends classes(Named, IdOnly) { + public constructor(id: string) { + super([], [id]); + } +} + +class ConflictingParents extends classes(Named, NamedSub, Identified) { + public constructor(id: string) { + super([], [], [id]); + } +} + +class DeepFirstParent extends classes(NamedSub, Named) {} + +@GhostMakerModel({ + name: "Multi", +}) +class Multi extends classes(Named, Identified) { + public constructor(id: string) { + super([], [id]); + } +} + +@GhostMakerModel({ + name: "MultiWithId", + getId: (model) => `multi-${model.id}`, +}) +class MultiWithId extends classes(Named, Identified) { + public constructor(id: string) { + super([], [id]); + } +} + +test("uses meta data of the class itself", () => { + expect(getModelName(new MultiWithId("m1"))).toBe("MultiWithId"); + expect(getModelId(new MultiWithId("m1"))).toBe("multi-m1"); +}); + +test("finds getId in any of multiple parent classes", () => { + expect(getModelName(new Multi("m1"))).toBe("Multi"); + expect(getModelId(new Multi("m1"))).toBe("m1"); +}); + +test("keeps working for models with complete meta data", () => { + expect(getModelName(new Identified("i1"))).toBe("Identified"); + expect(getModelId(new Identified("i1"))).toBe("i1"); +}); + +test("returns partial meta data of models without getId", () => { + expect(getModelName(new Named())).toBe("Named"); + expect(getModelId(new Named())).toBeUndefined(); +}); + +test("returns partial meta data of models without name", () => { + expect(getModelName(new IdOnly("i1"))).toBeUndefined(); + expect(getModelId(new IdOnly("i1"))).toBe("id-only-i1"); +}); + +test("inherits partial meta data from a single parent class", () => { + expect(getModelName(new NamedSub())).toBe("NamedBase"); +}); + +test("merges meta data of multiple parent classes", () => { + expect(getModelName(new MergedParents("m1"))).toBe("Named"); + expect(getModelId(new MergedParents("m1"))).toBe("id-only-m1"); +}); + +test("prefers meta data of earlier parent classes", () => { + expect(getModelName(new ConflictingParents("c1"))).toBe("Named"); + expect(getModelId(new ConflictingParents("c1"))).toBe("c1"); +}); + +test("searches ancestors of earlier parent classes first", () => { + expect(getModelName(new DeepFirstParent())).toBe("NamedBase"); +}); diff --git a/packages/react-ghostmaker/src/queries.test.ts b/packages/react-ghostmaker/src/queries.test.ts index 9a85e02..4ca871d 100644 --- a/packages/react-ghostmaker/src/queries.test.ts +++ b/packages/react-ghostmaker/src/queries.test.ts @@ -1,11 +1,13 @@ -import { getMetaData, GhostMakerModel } from "./metaData.ts"; -import { expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; +import { queries } from "./queries.ts"; +import { GhostMakerModel } from "./metaData.ts"; +import { registerModelIdentifier } from "./modelIdentifier.ts"; @GhostMakerModel({ - name: "TheGrandParent", - getId: (grandParent) => grandParent.id, + name: "QueryModel", + getId: (model) => model.id, }) -class GrandParent { +class QueryModel { public readonly id: string; public constructor(id: string) { @@ -14,25 +16,191 @@ class GrandParent { } @GhostMakerModel({ - name: "TheParent", + name: "NamedOnly", }) -class Parent extends GrandParent {} +class NamedOnly {} -@GhostMakerModel({ - name: "TheChild", -}) -class Child extends Parent {} +class PlainClass {} + +class LegacyModel { + public readonly legacyId: string; + + public constructor(legacyId: string) { + this.legacyId = legacyId; + } +} + +registerModelIdentifier((model) => + model instanceof LegacyModel ? `legacy-${model.legacyId}` : undefined, +); + +registerModelIdentifier((model) => + model instanceof LegacyModel ? "second" : undefined, +); -test("getMetaData returns correct names", () => { - expect(getMetaData(new Child("id"))?.name).toBe("TheChild"); - expect(getMetaData(new Parent("id"))?.name).toBe("TheParent"); - expect(getMetaData(Parent)?.name).toBe("TheParent"); - expect(getMetaData(Child)?.name).toBe("TheChild"); +describe("queries.ghostmaker()", () => { + test("returns the root query key", () => { + expect(queries.ghostmaker()).toEqual(["react-ghostmaker"]); + }); }); -test("getMetaData returns getId function", () => { - expect(getMetaData(new Child("id"))?.getId).toBeDefined(); - expect(getMetaData(new Parent("id"))?.getId).toBeDefined(); - expect(getMetaData(Parent)?.getId).toBeDefined(); - expect(getMetaData(Child)?.getId).toBeDefined(); +describe("queries.target()", () => { + test.each([ + ["string", "string"], + [42, "42"], + [true, "true"], + [null, "null"], + [undefined, "undefined"], + ])("uses primitive %s as is", (target, expected) => { + expect(queries.target(target)).toEqual(["react-ghostmaker", expected]); + }); + + test("uses model name and id of decorated models", () => { + expect(queries.target(new QueryModel("m1"))).toEqual([ + "react-ghostmaker", + "QueryModel@m1", + ]); + }); + + test("uses model name of decorated model classes", () => { + expect(queries.target(QueryModel)).toEqual([ + "react-ghostmaker", + "QueryModel", + ]); + }); + + test("uses model name of decorated models without id", () => { + expect(queries.target(new NamedOnly())).toEqual([ + "react-ghostmaker", + "NamedOnly", + ]); + }); + + test("uses class name of undecorated classes and instances", () => { + expect(queries.target(PlainClass)).toEqual([ + "react-ghostmaker", + "PlainClass", + ]); + expect(queries.target(new PlainClass())).toEqual([ + "react-ghostmaker", + "PlainClass", + ]); + }); + + test("uses function name of functions", () => { + function someFunction() { + // noop + } + expect(queries.target(someFunction)).toEqual([ + "react-ghostmaker", + "someFunction", + ]); + }); + + test("uses Object for plain objects", () => { + expect(queries.target({ a: 1 })).toEqual(["react-ghostmaker", "Object"]); + }); + + test("uses all matching registered model identifiers", () => { + expect(queries.target(new LegacyModel("l1"))).toEqual([ + "react-ghostmaker", + "LegacyModel@legacy-l1.second", + ]); + }); +}); + +describe("queries.chainItem()", () => { + const prev = ["react-ghostmaker", "Target"]; + + test("appends the property name for property access", () => { + expect(queries.chainItem(prev, { propName: "name" })).toEqual([ + ...prev, + "name", + ]); + }); + + test("appends only the property name for calls without args", () => { + expect(queries.chainItem(prev, { propName: "getAll", args: [] })).toEqual([ + ...prev, + "getAll", + ]); + }); + + test("appends primitive args as strings", () => { + expect( + queries.chainItem(prev, { + propName: "find", + args: ["id", 1, false, null, undefined], + }), + ).toEqual([...prev, "find", "id", "1", "false", "null", "undefined"]); + }); + + test("appends object args as name and hash", () => { + const [key] = queries + .chainItem(prev, { propName: "find", args: [{ filter: "a" }] }) + .slice(-1); + + expect(key).toMatch(/^Object\(hash:-?\d+\)$/); + }); + + test("uses the model name for model args", () => { + const [key] = queries + .chainItem(prev, { propName: "find", args: [new QueryModel("m1")] }) + .slice(-1); + + expect(key).toMatch(/^QueryModel\(hash:-?\d+\)$/); + }); + + test("object args with equal content produce equal keys", () => { + const keyA = queries.chainItem(prev, { + propName: "find", + args: [{ filter: "a", page: 1 }], + }); + const keyB = queries.chainItem(prev, { + propName: "find", + args: [{ filter: "a", page: 1 }], + }); + + expect(keyA).toEqual(keyB); + }); + + test("object args with different content produce different keys", () => { + const keyA = queries.chainItem(prev, { + propName: "find", + args: [{ filter: "a" }], + }); + const keyB = queries.chainItem(prev, { + propName: "find", + args: [{ filter: "b" }], + }); + + expect(keyA).not.toEqual(keyB); + }); +}); + +describe("queries.ghostChain()", () => { + test("returns the target key for an empty chain", () => { + expect(queries.ghostChain(QueryModel, [])).toEqual( + queries.target(QueryModel), + ); + }); + + test("builds the key of the whole chain", () => { + expect( + queries.ghostChain(QueryModel, [ + { propName: "ofId", args: ["m1"] }, + { propName: "getDetailed", args: [] }, + { propName: "customer" }, + { propName: "getName", args: [] }, + ]), + ).toEqual([ + "react-ghostmaker", + "QueryModel", + "ofId", + "m1", + "getDetailed", + "customer", + "getName", + ]); + }); }); diff --git a/packages/react-ghostmaker/src/reset.test.ts b/packages/react-ghostmaker/src/reset.test.ts new file mode 100644 index 0000000..c4d4a9c --- /dev/null +++ b/packages/react-ghostmaker/src/reset.test.ts @@ -0,0 +1,37 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, expect, test } from "vitest"; +import { resetGhosts } from "./reset.ts"; + +const queryClient = new QueryClient(); + +const keys = { + root: ["react-ghostmaker"], + target: ["react-ghostmaker", "Target"], + chain: ["react-ghostmaker", "Target", "getA"], + dependent: ["react-ghostmaker", "Target", "getA", "getB"], + sibling: ["react-ghostmaker", "Target", "getC"], + other: ["react-ghostmaker", "Other"], +}; + +beforeEach(() => { + queryClient.clear(); + for (const key of Object.values(keys)) { + queryClient.setQueryData(key, "data"); + } +}); + +test("resets the ghost, its dependents and its ancestors", async () => { + await resetGhosts(queryClient, keys.chain); + + expect(queryClient.getQueryData(keys.root)).toBeUndefined(); + expect(queryClient.getQueryData(keys.target)).toBeUndefined(); + expect(queryClient.getQueryData(keys.chain)).toBeUndefined(); + expect(queryClient.getQueryData(keys.dependent)).toBeUndefined(); +}); + +test("does not reset siblings or other targets", async () => { + await resetGhosts(queryClient, keys.chain); + + expect(queryClient.getQueryData(keys.sibling)).toBe("data"); + expect(queryClient.getQueryData(keys.other)).toBe("data"); +}); diff --git a/packages/react-ghostmaker/src/resolveGhostChain.test.ts b/packages/react-ghostmaker/src/resolveGhostChain.test.ts new file mode 100644 index 0000000..f0038bc --- /dev/null +++ b/packages/react-ghostmaker/src/resolveGhostChain.test.ts @@ -0,0 +1,108 @@ +import { expect, test, vitest } from "vitest"; +import { resolveGhostChain } from "./resolveGhostChain.ts"; +import { transformFnProp } from "./types.ts"; + +class Counter { + public readonly start: number; + + public constructor(start: number) { + this.start = start; + } + + public add(value: number) { + return new Counter(this.start + value); + } + + public async addAsync(value: number) { + return this.add(value); + } + + public async fail(): Promise { + throw new Error("failed"); + } + + public nothing(): Counter | undefined { + return undefined; + } +} + +test("returns the target for an empty chain", async () => { + const target = new Counter(1); + expect(await resolveGhostChain(target, [])).toBe(target); +}); + +test("resolves property access", async () => { + expect(await resolveGhostChain(new Counter(1), [{ propName: "start" }])).toBe( + 1, + ); +}); + +test("calls methods with args bound to their target", async () => { + expect( + await resolveGhostChain(new Counter(1), [ + { propName: "add", args: [2] }, + { propName: "addAsync", args: [3] }, + { propName: "start" }, + ]), + ).toBe(6); +}); + +test("resolves to undefined after a nullish result", async () => { + expect( + await resolveGhostChain(new Counter(1), [ + { propName: "nothing", args: [] }, + { propName: "add", args: [1] }, + { propName: "start" }, + ]), + ).toBeUndefined(); +}); + +test("applies transform functions", async () => { + const transform = vitest.fn((counter: Counter) => counter.start * 10); + + expect( + await resolveGhostChain(new Counter(1), [ + { propName: "add", args: [1] }, + { propName: transformFnProp, args: [transform] }, + ]), + ).toBe(20); + expect(transform).toHaveBeenCalledOnce(); +}); + +test("applies transform functions to nullish results", async () => { + const transform = vitest.fn(() => "fallback"); + + expect( + await resolveGhostChain(new Counter(1), [ + { propName: "nothing", args: [] }, + { propName: transformFnProp, args: [transform] }, + ]), + ).toBe("fallback"); + expect(transform).toHaveBeenCalledWith(undefined); +}); + +test("rejects when a transform has no mapping function", async () => { + await expect( + resolveGhostChain(new Counter(1), [ + { propName: transformFnProp, args: [] }, + ]), + ).rejects.toThrow("transform requires a mapping function"); +}); + +test("rejects when calling a property that is not a function", async () => { + await expect( + resolveGhostChain(new Counter(1), [{ propName: "start", args: [] }]), + ).rejects.toThrow(TypeError); +}); + +test("rejects when accessing a property of a primitive", async () => { + await expect( + resolveGhostChain("string", [{ propName: "length" }]), + ).rejects.toThrow(TypeError); +}); + +test("rejects when a method rejects", async () => { + await expect( + resolveGhostChain(new Counter(1), [{ propName: "fail", args: [] }]), + ).rejects.toThrow("failed"); +}); diff --git a/packages/react-ghostmaker/src/types.test.ts b/packages/react-ghostmaker/src/types.test.ts new file mode 100644 index 0000000..91b42a6 --- /dev/null +++ b/packages/react-ghostmaker/src/types.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { makeGhost } from "./makeGhost.ts"; +import { isReactGhost } from "./types.ts"; + +test("isReactGhost() detects ghosts", () => { + const ghost = makeGhost({ foo: () => "bar" }); + + expect(isReactGhost(ghost)).toBe(true); + expect(isReactGhost(ghost.foo)).toBe(true); + expect(isReactGhost(ghost.foo())).toBe(true); +}); + +test.each([ + ["a function", () => true], + ["an object", { ___ghostMarker: true }], + ["a string", "ghost"], + ["null", null], + ["undefined", undefined], +])("isReactGhost() returns false for %s", (_, something) => { + expect(isReactGhost(something)).toBe(false); +}); diff --git a/packages/react-ghostmaker/src/withEachGhost.test.ts b/packages/react-ghostmaker/src/withEachGhost.test.ts new file mode 100644 index 0000000..8fe9869 --- /dev/null +++ b/packages/react-ghostmaker/src/withEachGhost.test.ts @@ -0,0 +1,38 @@ +import { expect, test, vitest } from "vitest"; +import { withEachGhost } from "./withEachGhost.ts"; + +test("calls the callback for each partial query key", async () => { + const callback = vitest.fn(async () => { + // noop + }); + + await withEachGhost(["a", "b", "c"], callback); + + expect(callback.mock.calls).toEqual([ + [{ queryKey: ["a"], exact: true }], + [{ queryKey: ["a", "b"], exact: true }], + [{ queryKey: ["a", "b", "c"], exact: false }], + ]); +}); + +test("awaits each callback before calling the next one", async () => { + const events: string[] = []; + + await withEachGhost(["a", "b"], async ({ queryKey }) => { + events.push(`start ${queryKey?.join(".")}`); + await Promise.resolve(); + events.push(`end ${queryKey?.join(".")}`); + }); + + expect(events).toEqual(["start a", "end a", "start a.b", "end a.b"]); +}); + +test("does not call the callback for an empty query key", async () => { + const callback = vitest.fn(async () => { + // noop + }); + + await withEachGhost([], callback); + + expect(callback).not.toHaveBeenCalled(); +});