1import { RuntimeContext } from "alchemy";
2import { APIError } from "better-auth/api";
3import { anonymous } from "better-auth/plugins/anonymous";
4import { describe, expect, it } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as HttpServerRequest from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import {
9 BetterAuth,
10 BetterAuthApiError,
11 isAPIErrorLike,
12 Memory,
13 mergeAPIErrorHeaders,
14} from "@/index.ts";
15
16const baseOptions = {
17 baseURL: "http://localhost:3000",
18 emailAndPassword: { enabled: true },
19 secret: "test-secret-test-secret-test-secret",
20} as const;
21
22/** Serve one web Request through the instance's `fetch` HttpEffect. */
23const serve = (
24 fetch: Effect.Effect<
25 HttpServerResponse.HttpServerResponse,
26 unknown,
27 | RuntimeContext
28 | HttpServerRequest.HttpServerRequest
29 | import("effect/Scope").Scope
30 >,
31 request: Request,
32) =>
33 fetch.pipe(
34 Effect.provideService(
35 HttpServerRequest.HttpServerRequest,
36 HttpServerRequest.fromWeb(request),
37 ),
38 Effect.flatMap((response) =>
39 Effect.sync(() => HttpServerResponse.toWeb(response)),
40 ),
41 Effect.orDie,
42 );
43
44const provideTestEnv = <A, E, R>(
45 effect: Effect.Effect<A, E, R>,
46): Effect.Effect<A, E, Exclude<R, RuntimeContext>> =>
47 effect.pipe(Effect.provide(RuntimeContext.phantom)) as Effect.Effect<
48 A,
49 E,
50 Exclude<R, RuntimeContext>
51 >;
52
53describe("BetterAuth (memory)", { tags: ["unit", "local"] }, () => {
54 it.live("signs up, signs in over HTTP, reads the session", () =>
55 Effect.gen(function* () {
56 const auth = yield* BetterAuth({
57 ...baseOptions,
58 basePath: "/auth",
59 });
60
61 // sign up through the effectified api proxy
62 const signUp = yield* auth.api.signUpEmail({
63 body: {
64 email: "user@example.com",
65 password: "password1234",
66 name: "Test User",
67 },
68 });
69 expect(signUp.user.email).toBe("user@example.com");
70
71 // sign in over the HTTP surface to capture the session cookie
72 const signInResponse = yield* serve(
73 auth.fetch,
74 new Request("http://localhost:3000/auth/sign-in/email", {
75 method: "POST",
76 headers: {
77 "content-type": "application/json",
78 "x-forwarded-for": "192.0.2.1",
79 },
80 body: JSON.stringify({
81 email: "user@example.com",
82 password: "password1234",
83 }),
84 }),
85 );
86 expect(signInResponse.status).toBe(200);
87 const setCookies = signInResponse.headers.getSetCookie();
88 expect(setCookies.length).toBeGreaterThan(0);
89 const cookie = setCookies.map((c) => c.split(";")[0]).join("; ");
90
91 // read the session back with explicit headers
92 const session = yield* auth.getSession(new Headers({ cookie }));
93 expect(session).not.toBeNull();
94 expect(session!.user.email).toBe("user@example.com");
95
96 // and via the ambient request overload
97 const ambient = yield* auth.getSession().pipe(
98 Effect.provideService(
99 HttpServerRequest.HttpServerRequest,
100 HttpServerRequest.fromWeb(
101 new Request("http://localhost:3000/anything", {
102 headers: { cookie },
103 }),
104 ),
105 ),
106 );
107 expect(ambient?.user.email).toBe("user@example.com");
108 }).pipe(Effect.provide(Memory()), provideTestEnv),
109 );
110
111 it.live("maps APIError to BetterAuthApiError with typed fields", () =>
112 Effect.gen(function* () {
113 const auth = yield* BetterAuth(baseOptions);
114 yield* auth.api.signUpEmail({
115 body: {
116 email: "wrong-pass@example.com",
117 password: "password1234",
118 name: "Wrong Pass",
119 },
120 });
121 const error = yield* auth.api
122 .signInEmail({
123 body: {
124 email: "wrong-pass@example.com",
125 password: "not-the-password",
126 },
127 })
128 .pipe(Effect.flip);
129 expect(error).toBeInstanceOf(BetterAuthApiError);
130 expect(error._tag).toBe("BetterAuthApiError");
131 expect(error.statusCode).toBe(401);
132 expect(error.status).toBe("UNAUTHORIZED");
133 expect(error.body?.code).toBeDefined();
134 expect(error.headers).toBeInstanceOf(Headers);
135 }).pipe(Effect.provide(Memory()), provideTestEnv),
136 );
137
138 it.live("plugin endpoints surface on the typed api proxy", () =>
139 Effect.gen(function* () {
140 const auth = yield* BetterAuth({
141 ...baseOptions,
142 plugins: [anonymous()],
143 });
144 // `signInAnonymous` only exists because the anonymous() plugin is in
145 // the options — this call type-checks AND runs through the proxy.
146 const result = yield* auth.api.signInAnonymous({});
147 expect(result?.user.isAnonymous).toBe(true);
148 }).pipe(Effect.provide(Memory()), provideTestEnv),
149 );
150
151 it("api proxy is not a thenable and caches wrappers", () => {
152 const program = Effect.gen(function* () {
153 const auth = yield* BetterAuth(baseOptions);
154 const api = auth.api as Record<string, unknown>;
155 expect(api.then).toBeUndefined();
156 expect(api.getSession).toBe(api.getSession);
157 expect(typeof api.getSession).toBe("function");
158 }).pipe(Effect.provide(Memory()), provideTestEnv, Effect.scoped);
159 return Effect.runPromise(program as Effect.Effect<void>);
160 });
161
162 it("mergeAPIErrorHeaders preserves symbol-carried set-cookie headers", () => {
163 const error = new APIError(
164 "UNAUTHORIZED",
165 { message: "nope", code: "INVALID_EMAIL_OR_PASSWORD" },
166 { "x-visible": "yes" },
167 );
168 const hidden = new Headers();
169 hidden.append("set-cookie", "a=1; Path=/");
170 hidden.append("set-cookie", "b=2; Path=/");
171 hidden.set("x-hidden", "also");
172 (error as unknown as Record<symbol, unknown>)[
173 Symbol.for("better-call:api-error-headers")
174 ] = hidden;
175
176 expect(isAPIErrorLike(error)).toBe(true);
177 const merged = mergeAPIErrorHeaders(error);
178 expect(merged.get("x-visible")).toBe("yes");
179 expect(merged.get("x-hidden")).toBe("also");
180 expect(merged.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]);
181
182 const wrapped = BetterAuthApiError.fromAPIError(error);
183 expect(wrapped.statusCode).toBe(401);
184 expect(wrapped.headers.getSetCookie()).toEqual([
185 "a=1; Path=/",
186 "b=2; Path=/",
187 ]);
188 });
189
190 it("isAPIErrorLike rejects non-APIError values", () => {
191 expect(isAPIErrorLike(new Error("plain"))).toBe(false);
192 expect(isAPIErrorLike({ name: "APIError", statusCode: 401 })).toBe(false);
193 expect(isAPIErrorLike(null)).toBe(false);
194 });
195});