263 lines · 9.5 KB · TypeScript
RawDownload
import { Context, Effect, Layer } from "effect";import { withQueryContext } from "@executor-js/fumadb/query";import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor";import type { FumaDb } from "./fuma-runtime";import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids";import { definePlugin, type AnyPlugin } from "./plugin";import type { ExecutorOwnerPolicyContext } from "./owner-policy";import type { CredentialProvider } from "./provider";import type { SqliteTestFumaDb } from "./sqlite-test-db"; // ---------------------------------------------------------------------------// makeTestConfig — build an ExecutorConfig backed by an in-memory FumaDB.// For unit tests, plugin authors validating their plugin, REPL experimentation.//// Defaults to a single tenant ("test-tenant") with a bound subject// ("test-subject"). Tests that need a pure-org executor can pass `subject:// null` via a spread override.// --------------------------------------------------------------------------- export type TestDatabaseBackend = "sqlite"; export type TestFumaDb = Pick<SqliteTestFumaDb, "db" | "close"> & {  readonly warm: () => Promise<void>;}; const makeLazyTestFumaDb = (options: {  readonly tables: ReturnType<typeof collectTables>;  readonly backend: TestDatabaseBackend;  readonly dataDir?: string;}): TestFumaDb => {  let started: Promise<SqliteTestFumaDb> | undefined;  const start = () => {    if (!started) {      started = import("./sqlite-test-db").then(({ createSqliteTestFumaDb }) =>        createSqliteTestFumaDb({          tables: options.tables,          namespace: "executor_test",          path: options.dataDir ? `${options.dataDir}/test.db` : undefined,        }),      );    }    return started;  };   const internal: FumaDb["internal"] = {    tables: options.tables,    count: async (table, value) => (await start()).db.internal.count(table, value),    create: async (table, values) => (await start()).db.internal.create(table, values),    createMany: async (table, values) => (await start()).db.internal.createMany(table, values),    deleteMany: async (table, value) => (await start()).db.internal.deleteMany(table, value),    findFirst: async (table, value) => (await start()).db.internal.findFirst(table, value),    findMany: async (table, value) => (await start()).db.internal.findMany(table, value),    transaction: async (run) => (await start()).db.internal.transaction(run),    updateMany: async (table, value) => (await start()).db.internal.updateMany(table, value),    upsert: async (table, value) => (await start()).db.internal.upsert(table, value),  };   const queryMethods = new Set<PropertyKey>([    "count",    "create",    "createMany",    "deleteMany",    "findFirst",    "findMany",    "transaction",    "updateMany",    "upsert",  ]);   const makeDb = (context?: ExecutorOwnerPolicyContext): FumaDb =>    new Proxy(      { internal: context === undefined ? internal : { ...internal, context } },      {        get(target, prop) {          if (prop === "internal") return target.internal;          if (prop === "withContext") {            return (nextContext: ExecutorOwnerPolicyContext) => makeDb(nextContext);          }          if (!queryMethods.has(prop)) return undefined;           return async (...args: unknown[]) => {            const actual = await start();            const actualDb =              context === undefined ? actual.db : withQueryContext(actual.db, context);            const method = Reflect.get(actualDb, prop) as (...innerArgs: unknown[]) => unknown;            return method.apply(actualDb, args);          };        },      },    ) as FumaDb;   const db = makeDb();   return {    db,    warm: async () => {      await start();    },    close: async () => {      if (!started) return;      await (await started).close();    },  };}; /** The OAuth callback the in-memory test executor advertises. The OAuth flow *  tests exercise `start` (authorization_code), which now REQUIRES an explicit *  `redirectUri` — there is no localhost default. The test authorization server *  accepts any redirect_uri for an unregistered client, so this stable value is *  what the test AS echoes back. Tests asserting the "missing redirectUri fails *  loudly" path override this with `redirectUri: null`. */export const TEST_OAUTH_REDIRECT_URI = "http://localhost/oauth/callback"; export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly []> = {  readonly tenant?: string;  readonly subject?: string | null;  readonly plugins?: TPlugins;  readonly backend?: TestDatabaseBackend;  readonly dataDir?: string;  readonly coreTools?: ExecutorConfig<TPlugins>["coreTools"];  /** Override the OAuth callback URL. Pass `null` to construct an executor with   *  no OAuth callback (exercises the fail-loud redirect path). */  readonly redirectUri?: string | null;  readonly oauthCallbackStateOrgSlug?: string;  readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];  readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"];}; export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(  options?: TestConfigOptions<TPlugins>,): Omit<ExecutorConfig<TPlugins>, "db"> & {  readonly db: FumaDb;  readonly testDb: TestFumaDb;} => {  const tenant = options?.tenant ?? "test-tenant";  const subject = options?.subject === undefined ? "test-subject" : options.subject;   const tables = collectTables();  const testDb = makeLazyTestFumaDb({    tables,    backend: options?.backend ?? "sqlite",    dataDir: options?.dataDir,  });  const db = withQueryContext(testDb.db, {    tenant,    subject,  } satisfies ExecutorOwnerPolicyContext);   // EXPLICIT OAuth callback: default to a stable test URL so the redirect flow  // tests work without the removed localhost default; `redirectUri: null` omits  // it so a test can exercise the fail-loud "no callback configured" path.  const redirectUri =    options?.redirectUri === undefined ? TEST_OAUTH_REDIRECT_URI : options.redirectUri;   return {    tenant: Tenant.make(tenant),    ...(subject != null ? { subject: Subject.make(subject) } : {}),    db,    plugins: options?.plugins,    coreTools: options?.coreTools,    testDb,    // Tests default to auto-accepting elicitation prompts.    onElicitation: "accept-all",    onIntegrationChange: options?.onIntegrationChange,    ...(redirectUri != null ? { redirectUri } : {}),    oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,    firstPartyOAuthClients: options?.firstPartyOAuthClients,  };}; export interface TestWorkspaceHarness<  TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[],> {  readonly config: ExecutorConfig<TPlugins> & { readonly testDb: TestFumaDb };  readonly executor: Executor<TPlugins>;  readonly testDb: TestFumaDb;  readonly tenant: string;  readonly subject: string | null;} export class TestWorkspace extends Context.Service<TestWorkspace, TestWorkspaceHarness>()(  "executor-sdk/TestWorkspace",) {  static readonly current = <    const TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[],  >() =>    Effect.gen(function* () {      const workspace = yield* TestWorkspace;      return workspace as TestWorkspaceHarness<TPlugins>;    });} export const makeTestWorkspaceHarness = <const TPlugins extends readonly AnyPlugin[] = readonly []>(  options?: TestConfigOptions<TPlugins>,) =>  Effect.acquireRelease(    Effect.gen(function* () {      const config = makeTestConfig(options);      const executor = yield* createExecutor(config);      return {        config,        executor,        testDb: config.testDb,        tenant: String(config.tenant),        subject: config.subject != null ? String(config.subject) : null,      } as const;    }),    ({ executor, testDb }) =>      executor        .close()        .pipe(          Effect.ignore,          Effect.andThen(Effect.promise(() => testDb.close()).pipe(Effect.ignore)),        ),  ); export const makeTestWorkspaceLayer = <const TPlugins extends readonly AnyPlugin[] = readonly []>(  options?: TestConfigOptions<TPlugins>,) =>  Layer.effect(TestWorkspace)(    makeTestWorkspaceHarness(options).pipe(      Effect.tap(({ testDb }) => Effect.promise(() => testDb.warm())),    ),  ); export const makeTestExecutor = <const TPlugins extends readonly AnyPlugin[] = readonly []>(  options?: TestConfigOptions<TPlugins>,) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor)); /** Built-in in-memory writable credential provider, contributed as a plugin *  so tests always have a default writable store for inline connection values. */export const memoryCredentialsPlugin = definePlugin(() => {  const store = new Map<string, string>();   const provider: CredentialProvider = {    key: ProviderKey.make("memory"),    writable: true,    get: (id) => Effect.sync(() => store.get(String(id)) ?? null),    set: (id, value) =>      Effect.sync(() => {        store.set(String(id), value);      }),    delete: (id) =>      Effect.sync(() => {        store.delete(String(id));      }),    list: () =>      Effect.sync(() =>        Array.from(store.keys()).map((key) => ({          id: ProviderItemId.make(key),          name: key,        })),      ),  };   return {    id: "memory-credentials" as const,    storage: () => ({}),    credentialProviders: [provider],  };}); // Back-compat alias removed: v1 `memorySecretsPlugin` is gone (no secrets).