Unified · Split
| @@ -0,0 +1,11 @@ | ||
| 1 | +import { Effect, Layer } from "effect"; | |
| 2 | + | |
| 3 | +import { ApiKeyService } from "./api-keys"; | |
| 4 | +import { ApiKeyManagementError } from "./api-key-errors"; | |
| 5 | + | |
| 6 | +export const ApiKeyServiceTestLayer = Layer.succeed(ApiKeyService)({ | |
| 7 | + validate: () => Effect.succeed(null), | |
| 8 | + listUserKeys: () => Effect.succeed([]), | |
| 9 | + createUserKey: () => Effect.fail(new ApiKeyManagementError({ cause: "unstubbed" })), | |
| 10 | + revokeUserKey: () => Effect.void, | |
| 11 | +}); | |
| @@ -0,0 +1,90 @@ | ||
| 1 | +import { requestHandler } from "@tanstack/react-start/server"; | |
| 2 | +import { Context, Effect, Layer } from "effect"; | |
| 3 | +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | |
| 4 | +import { HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + AutumnTestLayer, | |
| 8 | + makeAutumnTestState, | |
| 9 | + type AutumnTestState, | |
| 10 | +} from "../services/autumn.test-layer"; | |
| 11 | +import { ApiKeyServiceTestLayer } from "./api-keys.test-layer"; | |
| 12 | +import { | |
| 13 | + makeUserStoreTestState, | |
| 14 | + UserStoreTestLayer, | |
| 15 | + type UserStoreTestState, | |
| 16 | +} from "./context.test-layer"; | |
| 17 | +import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, NonProtectedApi } from "./handlers"; | |
| 18 | +import { | |
| 19 | + makeSessionTestContext, | |
| 20 | + SessionAuthTestLayer, | |
| 21 | + type SessionTestContext, | |
| 22 | +} from "./middleware.test-layer"; | |
| 23 | +import { makeWorkOSTestState, WorkOSTestLayer, type WorkOSTestState } from "./workos.test-layer"; | |
| 24 | + | |
| 25 | +const TEST_BASE_URL = "http://test.local"; | |
| 26 | +const SESSION_COOKIE = "wos-session=test_session"; | |
| 27 | + | |
| 28 | +export type CloudAuthApiTestState = { | |
| 29 | + readonly workos: WorkOSTestState; | |
| 30 | + readonly autumn: AutumnTestState; | |
| 31 | + readonly userStore: UserStoreTestState; | |
| 32 | + readonly session: SessionTestContext; | |
| 33 | +}; | |
| 34 | + | |
| 35 | +export const makeCloudAuthApiTestState = ( | |
| 36 | + overrides: Partial<CloudAuthApiTestState> = {}, | |
| 37 | +): CloudAuthApiTestState => ({ | |
| 38 | + workos: makeWorkOSTestState(), | |
| 39 | + autumn: makeAutumnTestState(), | |
| 40 | + userStore: makeUserStoreTestState(), | |
| 41 | + session: makeSessionTestContext(), | |
| 42 | + ...overrides, | |
| 43 | +}); | |
| 44 | + | |
| 45 | +const makeCloudAuthApiTestClient = (state: CloudAuthApiTestState) => { | |
| 46 | + const ApiLive = HttpApiBuilder.layer(NonProtectedApi).pipe( | |
| 47 | + Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), | |
| 48 | + Layer.provide(SessionAuthTestLayer(state.session)), | |
| 49 | + Layer.provideMerge(WorkOSTestLayer(state.workos)), | |
| 50 | + Layer.provideMerge(AutumnTestLayer(state.autumn)), | |
| 51 | + Layer.provideMerge(UserStoreTestLayer(state.userStore)), | |
| 52 | + Layer.provideMerge(ApiKeyServiceTestLayer), | |
| 53 | + Layer.provideMerge(HttpServer.layerServices), | |
| 54 | + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), | |
| 55 | + ); | |
| 56 | + | |
| 57 | + const handler = HttpRouter.toWebHandler(ApiLive, { disableLogger: true }).handler; | |
| 58 | + const startHandler = requestHandler((request) => handler(request, undefined)); | |
| 59 | + const fetchViaHandler: typeof globalThis.fetch = async (input, init) => { | |
| 60 | + const request = input instanceof Request ? input : new Request(input, init); | |
| 61 | + request.headers.set("cookie", SESSION_COOKIE); | |
| 62 | + return await startHandler(request, undefined); | |
| 63 | + }; | |
| 64 | + const clientLayer = FetchHttpClient.layer.pipe( | |
| 65 | + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchViaHandler)), | |
| 66 | + ); | |
| 67 | + | |
| 68 | + return HttpApiClient.make(NonProtectedApi, { baseUrl: TEST_BASE_URL }).pipe( | |
| 69 | + Effect.provide(clientLayer), | |
| 70 | + ); | |
| 71 | +}; | |
| 72 | + | |
| 73 | +export type CloudAuthApiTestClient = Effect.Success<ReturnType<typeof makeCloudAuthApiTestClient>>; | |
| 74 | + | |
| 75 | +export class CloudAuthApiTestContext extends Context.Service< | |
| 76 | + CloudAuthApiTestContext, | |
| 77 | + { | |
| 78 | + readonly state: CloudAuthApiTestState; | |
| 79 | + readonly client: CloudAuthApiTestClient; | |
| 80 | + } | |
| 81 | +>()("CloudAuthApiTestContext") {} | |
| 82 | + | |
| 83 | +export const CloudAuthApiTestContextLayer = (state = makeCloudAuthApiTestState()) => | |
| 84 | + Layer.effect( | |
| 85 | + CloudAuthApiTestContext, | |
| 86 | + Effect.gen(function* () { | |
| 87 | + const client = yield* makeCloudAuthApiTestClient(state); | |
| 88 | + return { state, client }; | |
| 89 | + }), | |
| 90 | + ); | |
| @@ -0,0 +1,41 @@ | ||
| 1 | +import { Effect, Layer } from "effect"; | |
| 2 | + | |
| 3 | +import { UserStoreService } from "./context"; | |
| 4 | + | |
| 5 | +type UserStore = Parameters<Parameters<UserStoreService["Service"]["use"]>[0]>[0]; | |
| 6 | + | |
| 7 | +export type UserStoreTestState = { | |
| 8 | + readonly upsertedOrganizations: Array<{ readonly id: string; readonly name: string }>; | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export const makeUserStoreTestState = ( | |
| 12 | + overrides: Partial<UserStoreTestState> = {}, | |
| 13 | +): UserStoreTestState => ({ | |
| 14 | + upsertedOrganizations: [], | |
| 15 | + ...overrides, | |
| 16 | +}); | |
| 17 | + | |
| 18 | +const testDate = () => new Date("2026-01-01T00:00:00.000Z"); | |
| 19 | + | |
| 20 | +const makeUserStoreTestService = (state: UserStoreTestState): UserStoreService["Service"] => { | |
| 21 | + const store = { | |
| 22 | + ensureAccount: async (id: string) => ({ id, createdAt: testDate() }), | |
| 23 | + getAccount: async (id: string) => ({ id, createdAt: testDate() }), | |
| 24 | + upsertOrganization: async (org: { readonly id: string; readonly name: string }) => { | |
| 25 | + state.upsertedOrganizations.push(org); | |
| 26 | + return { ...org, createdAt: testDate() }; | |
| 27 | + }, | |
| 28 | + getOrganization: async (id: string) => ({ | |
| 29 | + id, | |
| 30 | + name: id, | |
| 31 | + createdAt: testDate(), | |
| 32 | + }), | |
| 33 | + } satisfies UserStore; | |
| 34 | + | |
| 35 | + return { | |
| 36 | + use: (fn) => Effect.promise(() => fn(store)), | |
| 37 | + }; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +export const UserStoreTestLayer = (state: UserStoreTestState) => | |
| 41 | + Layer.succeed(UserStoreService)(makeUserStoreTestService(state)); | |
| @@ -0,0 +1,23 @@ | ||
| 1 | +import { Effect, Layer } from "effect"; | |
| 2 | + | |
| 3 | +import { SessionAuth, SessionContext, type Session } from "./middleware"; | |
| 4 | + | |
| 5 | +export type SessionTestContext = Session; | |
| 6 | + | |
| 7 | +export const makeSessionTestContext = ( | |
| 8 | + overrides: Partial<SessionTestContext> = {}, | |
| 9 | +): SessionTestContext => ({ | |
| 10 | + accountId: "user_1", | |
| 11 | + email: "test@example.com", | |
| 12 | + name: "Test User", | |
| 13 | + avatarUrl: null, | |
| 14 | + organizationId: "org_existing_1", | |
| 15 | + sealedSession: "test_session", | |
| 16 | + refreshedSession: null, | |
| 17 | + ...overrides, | |
| 18 | +}); | |
| 19 | + | |
| 20 | +export const SessionAuthTestLayer = (session: Session = makeSessionTestContext()) => | |
| 21 | + Layer.succeed(SessionAuth)({ | |
| 22 | + cookie: (httpEffect) => Effect.provideService(httpEffect, SessionContext, session), | |
| 23 | + }); | |
| @@ -0,0 +1,122 @@ | ||
| 1 | +import { Data, Effect, Layer } from "effect"; | |
| 2 | +import type { Organization, OrganizationMembership, OrganizationRole } from "@workos-inc/node"; | |
| 3 | + | |
| 4 | +import { WorkOSAuth, type WorkOSCollectedList } from "./workos"; | |
| 5 | + | |
| 6 | +export type WorkOSTestState = { | |
| 7 | + readonly memberships: readonly OrganizationMembership[]; | |
| 8 | + readonly createdOrganizations: Array<{ readonly id: string; readonly name: string }>; | |
| 9 | + readonly createdMemberships: Array<{ | |
| 10 | + readonly organizationId: string; | |
| 11 | + readonly userId: string; | |
| 12 | + readonly roleSlug: string | undefined; | |
| 13 | + }>; | |
| 14 | +}; | |
| 15 | + | |
| 16 | +export class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{ | |
| 17 | + readonly method: string; | |
| 18 | +}> {} | |
| 19 | + | |
| 20 | +export const makeWorkOSTestState = (overrides: Partial<WorkOSTestState> = {}): WorkOSTestState => ({ | |
| 21 | + memberships: [], | |
| 22 | + createdOrganizations: [], | |
| 23 | + createdMemberships: [], | |
| 24 | + ...overrides, | |
| 25 | +}); | |
| 26 | + | |
| 27 | +export const WorkOSTestRole: OrganizationRole = { | |
| 28 | + object: "role", | |
| 29 | + id: "role_admin", | |
| 30 | + name: "Admin", | |
| 31 | + slug: "admin", | |
| 32 | + description: null, | |
| 33 | + permissions: [], | |
| 34 | + resourceTypeSlug: "organization", | |
| 35 | + type: "OrganizationRole", | |
| 36 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 37 | + updatedAt: "2026-01-01T00:00:00.000Z", | |
| 38 | +}; | |
| 39 | + | |
| 40 | +export const makeWorkOSTestOrganization = (id: string, name = id): Organization => ({ | |
| 41 | + object: "organization", | |
| 42 | + id, | |
| 43 | + name, | |
| 44 | + allowProfilesOutsideOrganization: false, | |
| 45 | + domains: [], | |
| 46 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 47 | + updatedAt: "2026-01-01T00:00:00.000Z", | |
| 48 | + externalId: null, | |
| 49 | + metadata: {}, | |
| 50 | +}); | |
| 51 | + | |
| 52 | +export const makeWorkOSTestMembership = ( | |
| 53 | + organizationId: string, | |
| 54 | + status: OrganizationMembership["status"], | |
| 55 | +) => | |
| 56 | + ({ | |
| 57 | + object: "organization_membership", | |
| 58 | + id: `membership_${organizationId}`, | |
| 59 | + organizationId, | |
| 60 | + organizationName: organizationId, | |
| 61 | + status, | |
| 62 | + userId: "user_1", | |
| 63 | + role: WorkOSTestRole, | |
| 64 | + directoryManaged: false, | |
| 65 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 66 | + updatedAt: "2026-01-01T00:00:00.000Z", | |
| 67 | + customAttributes: {}, | |
| 68 | + }) satisfies OrganizationMembership; | |
| 69 | + | |
| 70 | +const collected = <A>(data: readonly A[]): WorkOSCollectedList<A> => ({ | |
| 71 | + object: "list", | |
| 72 | + data: [...data], | |
| 73 | + listMetadata: { | |
| 74 | + before: null, | |
| 75 | + after: null, | |
| 76 | + }, | |
| 77 | +}); | |
| 78 | + | |
| 79 | +const makeWorkOSTestService = (state: WorkOSTestState): WorkOSAuth["Service"] => { | |
| 80 | + const nextOrgId = "org_created"; | |
| 81 | + const service: Partial<WorkOSAuth["Service"]> = { | |
| 82 | + listUserMemberships: () => Effect.succeed(collected(state.memberships)), | |
| 83 | + createOrganization: (name) => | |
| 84 | + Effect.sync(() => { | |
| 85 | + const org = makeWorkOSTestOrganization(nextOrgId, name); | |
| 86 | + state.createdOrganizations.push({ id: org.id, name: org.name }); | |
| 87 | + return org; | |
| 88 | + }), | |
| 89 | + createMembership: (organizationId, userId, roleSlug) => | |
| 90 | + Effect.sync(() => { | |
| 91 | + state.createdMemberships.push({ organizationId, userId, roleSlug }); | |
| 92 | + return makeWorkOSTestMembership(organizationId, "active"); | |
| 93 | + }), | |
| 94 | + refreshSession: (_sealedSession, organizationId) => Effect.succeed(`session_${organizationId}`), | |
| 95 | + authenticateSealedSession: (sealedSession) => | |
| 96 | + Effect.succeed({ | |
| 97 | + userId: "user_1", | |
| 98 | + email: "test@example.com", | |
| 99 | + firstName: "Test", | |
| 100 | + lastName: "User", | |
| 101 | + avatarUrl: null, | |
| 102 | + organizationId: sealedSession.replace("session_", ""), | |
| 103 | + sessionId: "session_id", | |
| 104 | + refreshedSession: undefined, | |
| 105 | + }), | |
| 106 | + }; | |
| 107 | + | |
| 108 | + return new Proxy(service as WorkOSAuth["Service"], { | |
| 109 | + get: (target, prop) => { | |
| 110 | + if (prop in target) return target[prop as keyof typeof target]; | |
| 111 | + return () => | |
| 112 | + Effect.fail( | |
| 113 | + new UnstubbedWorkOSMethod({ | |
| 114 | + method: typeof prop === "string" ? prop : (prop.description ?? "symbol"), | |
| 115 | + }), | |
| 116 | + ); | |
| 117 | + }, | |
| 118 | + }); | |
| 119 | +}; | |
| 120 | + | |
| 121 | +export const WorkOSTestLayer = (state: WorkOSTestState) => | |
| 122 | + Layer.succeed(WorkOSAuth)(makeWorkOSTestService(state)); | |
| @@ -0,0 +1,36 @@ | ||
| 1 | +import { Effect, Layer } from "effect"; | |
| 2 | +import type { Autumn } from "autumn-js"; | |
| 3 | + | |
| 4 | +import { AutumnService, type IAutumnService } from "./autumn"; | |
| 5 | + | |
| 6 | +export type AutumnTestSubscriptionSummary = { | |
| 7 | + readonly planId?: string | null; | |
| 8 | + readonly status?: string | null; | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export type AutumnTestState = { | |
| 12 | + readonly subscriptionsByOrgId: Readonly<Record<string, readonly AutumnTestSubscriptionSummary[]>>; | |
| 13 | +}; | |
| 14 | + | |
| 15 | +export const makeAutumnTestState = (overrides: Partial<AutumnTestState> = {}): AutumnTestState => ({ | |
| 16 | + subscriptionsByOrgId: {}, | |
| 17 | + ...overrides, | |
| 18 | +}); | |
| 19 | + | |
| 20 | +const makeAutumnTestService = (state: AutumnTestState): IAutumnService => { | |
| 21 | + const fakeClient = { | |
| 22 | + customers: { | |
| 23 | + getOrCreate: async ({ customerId }: { readonly customerId: string }) => ({ | |
| 24 | + subscriptions: state.subscriptionsByOrgId[customerId] ?? [], | |
| 25 | + }), | |
| 26 | + }, | |
| 27 | + } as Autumn; | |
| 28 | + | |
| 29 | + return { | |
| 30 | + use: (fn) => Effect.promise(() => fn(fakeClient)), | |
| 31 | + trackExecution: () => Effect.void, | |
| 32 | + }; | |
| 33 | +}; | |
| 34 | + | |
| 35 | +export const AutumnTestLayer = (state: AutumnTestState) => | |
| 36 | + Layer.succeed(AutumnService)(makeAutumnTestService(state)); | |