diff --git a/apps/cloud/src/auth/api-keys.test-layer.ts b/apps/cloud/src/auth/api-keys.test-layer.ts new file mode 100644 index 000000000..e190ce520 --- /dev/null +++ b/apps/cloud/src/auth/api-keys.test-layer.ts @@ -0,0 +1,11 @@ +import { Effect, Layer } from "effect"; + +import { ApiKeyService } from "./api-keys"; +import { ApiKeyManagementError } from "./api-key-errors"; + +export const ApiKeyServiceTestLayer = Layer.succeed(ApiKeyService)({ + validate: () => Effect.succeed(null), + listUserKeys: () => Effect.succeed([]), + createUserKey: () => Effect.fail(new ApiKeyManagementError({ cause: "unstubbed" })), + revokeUserKey: () => Effect.void, +}); diff --git a/apps/cloud/src/auth/cloud-auth-api.test-context.ts b/apps/cloud/src/auth/cloud-auth-api.test-context.ts new file mode 100644 index 000000000..c81d6377e --- /dev/null +++ b/apps/cloud/src/auth/cloud-auth-api.test-context.ts @@ -0,0 +1,90 @@ +import { requestHandler } from "@tanstack/react-start/server"; +import { Context, Effect, Layer } from "effect"; +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; +import { HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"; + +import { + AutumnTestLayer, + makeAutumnTestState, + type AutumnTestState, +} from "../services/autumn.test-layer"; +import { ApiKeyServiceTestLayer } from "./api-keys.test-layer"; +import { + makeUserStoreTestState, + UserStoreTestLayer, + type UserStoreTestState, +} from "./context.test-layer"; +import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, NonProtectedApi } from "./handlers"; +import { + makeSessionTestContext, + SessionAuthTestLayer, + type SessionTestContext, +} from "./middleware.test-layer"; +import { makeWorkOSTestState, WorkOSTestLayer, type WorkOSTestState } from "./workos.test-layer"; + +const TEST_BASE_URL = "http://test.local"; +const SESSION_COOKIE = "wos-session=test_session"; + +export type CloudAuthApiTestState = { + readonly workos: WorkOSTestState; + readonly autumn: AutumnTestState; + readonly userStore: UserStoreTestState; + readonly session: SessionTestContext; +}; + +export const makeCloudAuthApiTestState = ( + overrides: Partial = {}, +): CloudAuthApiTestState => ({ + workos: makeWorkOSTestState(), + autumn: makeAutumnTestState(), + userStore: makeUserStoreTestState(), + session: makeSessionTestContext(), + ...overrides, +}); + +const makeCloudAuthApiTestClient = (state: CloudAuthApiTestState) => { + const ApiLive = HttpApiBuilder.layer(NonProtectedApi).pipe( + Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), + Layer.provide(SessionAuthTestLayer(state.session)), + Layer.provideMerge(WorkOSTestLayer(state.workos)), + Layer.provideMerge(AutumnTestLayer(state.autumn)), + Layer.provideMerge(UserStoreTestLayer(state.userStore)), + Layer.provideMerge(ApiKeyServiceTestLayer), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ); + + const handler = HttpRouter.toWebHandler(ApiLive, { disableLogger: true }).handler; + const startHandler = requestHandler((request) => handler(request, undefined)); + const fetchViaHandler: typeof globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + request.headers.set("cookie", SESSION_COOKIE); + return await startHandler(request, undefined); + }; + const clientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchViaHandler)), + ); + + return HttpApiClient.make(NonProtectedApi, { baseUrl: TEST_BASE_URL }).pipe( + Effect.provide(clientLayer), + ); +}; + +export type CloudAuthApiTestClient = Effect.Success>; + +export class CloudAuthApiTestContext extends Context.Service< + CloudAuthApiTestContext, + { + readonly state: CloudAuthApiTestState; + readonly client: CloudAuthApiTestClient; + } +>()("CloudAuthApiTestContext") {} + +export const CloudAuthApiTestContextLayer = (state = makeCloudAuthApiTestState()) => + Layer.effect( + CloudAuthApiTestContext, + Effect.gen(function* () { + const client = yield* makeCloudAuthApiTestClient(state); + return { state, client }; + }), + ); diff --git a/apps/cloud/src/auth/context.test-layer.ts b/apps/cloud/src/auth/context.test-layer.ts new file mode 100644 index 000000000..e1d3c6b3e --- /dev/null +++ b/apps/cloud/src/auth/context.test-layer.ts @@ -0,0 +1,41 @@ +import { Effect, Layer } from "effect"; + +import { UserStoreService } from "./context"; + +type UserStore = Parameters[0]>[0]; + +export type UserStoreTestState = { + readonly upsertedOrganizations: Array<{ readonly id: string; readonly name: string }>; +}; + +export const makeUserStoreTestState = ( + overrides: Partial = {}, +): UserStoreTestState => ({ + upsertedOrganizations: [], + ...overrides, +}); + +const testDate = () => new Date("2026-01-01T00:00:00.000Z"); + +const makeUserStoreTestService = (state: UserStoreTestState): UserStoreService["Service"] => { + const store = { + ensureAccount: async (id: string) => ({ id, createdAt: testDate() }), + getAccount: async (id: string) => ({ id, createdAt: testDate() }), + upsertOrganization: async (org: { readonly id: string; readonly name: string }) => { + state.upsertedOrganizations.push(org); + return { ...org, createdAt: testDate() }; + }, + getOrganization: async (id: string) => ({ + id, + name: id, + createdAt: testDate(), + }), + } satisfies UserStore; + + return { + use: (fn) => Effect.promise(() => fn(store)), + }; +}; + +export const UserStoreTestLayer = (state: UserStoreTestState) => + Layer.succeed(UserStoreService)(makeUserStoreTestService(state)); diff --git a/apps/cloud/src/auth/middleware.test-layer.ts b/apps/cloud/src/auth/middleware.test-layer.ts new file mode 100644 index 000000000..4502a7921 --- /dev/null +++ b/apps/cloud/src/auth/middleware.test-layer.ts @@ -0,0 +1,23 @@ +import { Effect, Layer } from "effect"; + +import { SessionAuth, SessionContext, type Session } from "./middleware"; + +export type SessionTestContext = Session; + +export const makeSessionTestContext = ( + overrides: Partial = {}, +): SessionTestContext => ({ + accountId: "user_1", + email: "test@example.com", + name: "Test User", + avatarUrl: null, + organizationId: "org_existing_1", + sealedSession: "test_session", + refreshedSession: null, + ...overrides, +}); + +export const SessionAuthTestLayer = (session: Session = makeSessionTestContext()) => + Layer.succeed(SessionAuth)({ + cookie: (httpEffect) => Effect.provideService(httpEffect, SessionContext, session), + }); diff --git a/apps/cloud/src/auth/workos.test-layer.ts b/apps/cloud/src/auth/workos.test-layer.ts new file mode 100644 index 000000000..d2d7f97da --- /dev/null +++ b/apps/cloud/src/auth/workos.test-layer.ts @@ -0,0 +1,122 @@ +import { Data, Effect, Layer } from "effect"; +import type { Organization, OrganizationMembership, OrganizationRole } from "@workos-inc/node"; + +import { WorkOSAuth, type WorkOSCollectedList } from "./workos"; + +export type WorkOSTestState = { + readonly memberships: readonly OrganizationMembership[]; + readonly createdOrganizations: Array<{ readonly id: string; readonly name: string }>; + readonly createdMemberships: Array<{ + readonly organizationId: string; + readonly userId: string; + readonly roleSlug: string | undefined; + }>; +}; + +export class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{ + readonly method: string; +}> {} + +export const makeWorkOSTestState = (overrides: Partial = {}): WorkOSTestState => ({ + memberships: [], + createdOrganizations: [], + createdMemberships: [], + ...overrides, +}); + +export const WorkOSTestRole: OrganizationRole = { + object: "role", + id: "role_admin", + name: "Admin", + slug: "admin", + description: null, + permissions: [], + resourceTypeSlug: "organization", + type: "OrganizationRole", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +export const makeWorkOSTestOrganization = (id: string, name = id): Organization => ({ + object: "organization", + id, + name, + allowProfilesOutsideOrganization: false, + domains: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + externalId: null, + metadata: {}, +}); + +export const makeWorkOSTestMembership = ( + organizationId: string, + status: OrganizationMembership["status"], +) => + ({ + object: "organization_membership", + id: `membership_${organizationId}`, + organizationId, + organizationName: organizationId, + status, + userId: "user_1", + role: WorkOSTestRole, + directoryManaged: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + customAttributes: {}, + }) satisfies OrganizationMembership; + +const collected = (data: readonly A[]): WorkOSCollectedList => ({ + object: "list", + data: [...data], + listMetadata: { + before: null, + after: null, + }, +}); + +const makeWorkOSTestService = (state: WorkOSTestState): WorkOSAuth["Service"] => { + const nextOrgId = "org_created"; + const service: Partial = { + listUserMemberships: () => Effect.succeed(collected(state.memberships)), + createOrganization: (name) => + Effect.sync(() => { + const org = makeWorkOSTestOrganization(nextOrgId, name); + state.createdOrganizations.push({ id: org.id, name: org.name }); + return org; + }), + createMembership: (organizationId, userId, roleSlug) => + Effect.sync(() => { + state.createdMemberships.push({ organizationId, userId, roleSlug }); + return makeWorkOSTestMembership(organizationId, "active"); + }), + refreshSession: (_sealedSession, organizationId) => Effect.succeed(`session_${organizationId}`), + authenticateSealedSession: (sealedSession) => + Effect.succeed({ + userId: "user_1", + email: "test@example.com", + firstName: "Test", + lastName: "User", + avatarUrl: null, + organizationId: sealedSession.replace("session_", ""), + sessionId: "session_id", + refreshedSession: undefined, + }), + }; + + return new Proxy(service as WorkOSAuth["Service"], { + get: (target, prop) => { + if (prop in target) return target[prop as keyof typeof target]; + return () => + Effect.fail( + new UnstubbedWorkOSMethod({ + method: typeof prop === "string" ? prop : (prop.description ?? "symbol"), + }), + ); + }, + }); +}; + +export const WorkOSTestLayer = (state: WorkOSTestState) => + Layer.succeed(WorkOSAuth)(makeWorkOSTestService(state)); diff --git a/apps/cloud/src/services/autumn.test-layer.ts b/apps/cloud/src/services/autumn.test-layer.ts new file mode 100644 index 000000000..b1c482d9e --- /dev/null +++ b/apps/cloud/src/services/autumn.test-layer.ts @@ -0,0 +1,36 @@ +import { Effect, Layer } from "effect"; +import type { Autumn } from "autumn-js"; + +import { AutumnService, type IAutumnService } from "./autumn"; + +export type AutumnTestSubscriptionSummary = { + readonly planId?: string | null; + readonly status?: string | null; +}; + +export type AutumnTestState = { + readonly subscriptionsByOrgId: Readonly>; +}; + +export const makeAutumnTestState = (overrides: Partial = {}): AutumnTestState => ({ + subscriptionsByOrgId: {}, + ...overrides, +}); + +const makeAutumnTestService = (state: AutumnTestState): IAutumnService => { + const fakeClient = { + customers: { + getOrCreate: async ({ customerId }: { readonly customerId: string }) => ({ + subscriptions: state.subscriptionsByOrgId[customerId] ?? [], + }), + }, + } as Autumn; + + return { + use: (fn) => Effect.promise(() => fn(fakeClient)), + trackExecution: () => Effect.void, + }; +}; + +export const AutumnTestLayer = (state: AutumnTestState) => + Layer.succeed(AutumnService)(makeAutumnTestService(state));