1import { AlchemyContext, RuntimeContext } from "alchemy";
2import { describe, expect, it } from "alchemy-test";
3import { organization } from "better-auth/plugins/organization";
4import * as BunFileSystem from "@effect/platform-bun/BunFileSystem";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Result from "effect/Result";
8import { BetterAuth, BetterAuthMigrationError, Database } from "@/index.ts";
9import { applyMigrations, schemaFingerprint } from "@/Migrate.ts";
10import { SQLite } from "@/SQLite.ts";
11
12const baseOptions = {
13 baseURL: "http://localhost:3000",
14 emailAndPassword: { enabled: true },
15 secret: "test-secret-test-secret-test-secret",
16} as const;
17
18const provideTestEnv = <A, E, R>(
19 effect: Effect.Effect<A, E, R>,
20): Effect.Effect<A, E, Exclude<R, RuntimeContext | FileSystem.FileSystem>> =>
21 effect.pipe(
22 Effect.provide(BunFileSystem.layer),
23 Effect.provide(RuntimeContext.phantom),
24 ) as Effect.Effect<A, E, Exclude<R, RuntimeContext | FileSystem.FileSystem>>;
25
26const tempSqlitePath = Effect.gen(function* () {
27 const fs = yield* FileSystem.FileSystem;
28 const dir = yield* fs.makeTempDirectory({ prefix: "better-auth-sqlite" });
29 return `${dir}/auth.sqlite`;
30});
31
32describe("BetterAuth (bun:sqlite)", { tags: ["unit", "local"] }, () => {
33 it.live(
34 "uses the configured runtime directory unless a filename is supplied",
35 () =>
36 Effect.gen(function* () {
37 const fs = yield* FileSystem.FileSystem;
38 const root = yield* fs.makeTempDirectoryScoped();
39 yield* Effect.gen(function* () {
40 for (const filename of [undefined, `${root}/explicit.sqlite`]) {
41 const expected = filename ?? `${root}/better-auth.sqlite`;
42 const db = yield* Database.pipe(Effect.provide(SQLite(filename)));
43 yield* applyMigrations(db.migrate!, baseOptions);
44 expect(yield* fs.exists(expected)).toBe(true);
45 }
46 }).pipe(
47 Effect.provideService(AlchemyContext, {
48 dotAlchemy: root,
49 dev: false,
50 adopt: false,
51 }),
52 );
53 }).pipe(Effect.scoped, provideTestEnv),
54 );
55
56 it.live("applies schema migrations idempotently", () =>
57 Effect.gen(function* () {
58 const path = yield* tempSqlitePath;
59 const db = yield* Database.pipe(Effect.provide(SQLite(path)));
60 expect(db.migrate).toBeDefined();
61
62 const first = yield* applyMigrations(db.migrate!, baseOptions);
63 expect(first.tablesCreated).toBeGreaterThan(0);
64
65 // re-running against an up-to-date database is a no-op
66 const second = yield* applyMigrations(db.migrate!, baseOptions);
67 expect(second.tablesCreated).toBe(0);
68 expect(second.tablesAltered).toBe(0);
69 expect(second.indexesCreated).toBe(0);
70
71 // verify the core tables actually exist in the file
72 const { Database: BunSqlite } = yield* Effect.promise(
73 () => import("bun:sqlite"),
74 );
75 const raw = new BunSqlite(path);
76 const tables = (
77 raw
78 .query("SELECT name FROM sqlite_master WHERE type = 'table'")
79 .all() as { name: string }[]
80 ).map((row) => row.name);
81 raw.close();
82 for (const table of ["user", "session", "account", "verification"]) {
83 expect(tables).toContain(table);
84 }
85 }).pipe(provideTestEnv),
86 );
87
88 it.live("runs the full auth flow against the migrated database", () =>
89 Effect.gen(function* () {
90 const path = yield* tempSqlitePath;
91 const layer = SQLite(path);
92 const db = yield* Database.pipe(Effect.provide(layer));
93 yield* applyMigrations(db.migrate!, baseOptions);
94
95 const auth = yield* BetterAuth(baseOptions).pipe(
96 // layer is path-parameterized per test — provided here rather than
97 // on the outer test effect
98 Effect.provide(layer),
99 );
100 const signUp = yield* auth.api.signUpEmail({
101 body: {
102 email: "sqlite@example.com",
103 password: "password1234",
104 name: "SQLite User",
105 },
106 });
107 expect(signUp.user.email).toBe("sqlite@example.com");
108
109 const signIn = yield* auth.api.signInEmail({
110 body: { email: "sqlite@example.com", password: "password1234" },
111 });
112 expect(signIn.user.email).toBe("sqlite@example.com");
113 expect(signIn.token).toBeDefined();
114
115 const session = yield* auth.api.getSession({
116 headers: new Headers({
117 authorization: `Bearer ${signIn.token}`,
118 }),
119 });
120 // sqlite persisted the user — a fresh read sees it
121 expect(signUp.user.id).toBeDefined();
122 void session;
123 }).pipe(provideTestEnv),
124 );
125
126 it.live(
127 "upgrades a populated 1.6 database without rewriting account identities",
128 () =>
129 Effect.gen(function* () {
130 const path = yield* tempSqlitePath;
131 const { Database: BunSqlite } = yield* Effect.promise(
132 () => import("bun:sqlite"),
133 );
134 const { betterAuth: betterAuth16 } = yield* Effect.promise(
135 () => import("better-auth-1.6"),
136 );
137 const { getMigrations } = yield* Effect.promise(
138 () => import("better-auth-1.6/db/migration"),
139 );
140 const legacy = yield* Effect.gen(function* () {
141 const database = yield* Effect.acquireRelease(
142 Effect.sync(() => new BunSqlite(path)),
143 (database) => Effect.sync(() => database.close()),
144 );
145 const migrations = yield* Effect.promise(() =>
146 getMigrations({ ...baseOptions, database }),
147 );
148 yield* Effect.promise(() => migrations.runMigrations());
149 const auth = yield* Effect.sync(() =>
150 betterAuth16({ ...baseOptions, database }),
151 );
152 const user = yield* Effect.promise(() =>
153 auth.api.signUpEmail({
154 body: {
155 email: "existing@example.com",
156 password: "password1234",
157 name: "Existing User",
158 },
159 }),
160 );
161 const signIn = yield* Effect.promise(() =>
162 auth.api.signInEmail({
163 body: { email: "existing@example.com", password: "password1234" },
164 asResponse: true,
165 }),
166 );
167 const accounts = yield* Effect.sync(() =>
168 database
169 .query(
170 "SELECT id, accountId, providerId, userId, password FROM account ORDER BY id",
171 )
172 .all(),
173 );
174 return {
175 userId: user.user.id,
176 accounts,
177 cookie: signIn.headers
178 .getSetCookie()
179 .map((cookie) => cookie.split(";")[0])
180 .join("; "),
181 };
182 }).pipe(Effect.scoped);
183
184 const layer = SQLite(path);
185 const database = yield* Database.pipe(Effect.provide(layer));
186 yield* applyMigrations(database.migrate!, baseOptions);
187 const auth = yield* BetterAuth(baseOptions).pipe(Effect.provide(layer));
188 const session = yield* auth.api.getSession({
189 headers: new Headers({ cookie: legacy.cookie }),
190 });
191 expect(session?.user.id).toBe(legacy.userId);
192 const signIn = yield* auth.api.signInEmail({
193 body: { email: "existing@example.com", password: "password1234" },
194 });
195 expect(signIn.user.id).toBe(legacy.userId);
196
197 yield* Effect.gen(function* () {
198 const raw = yield* Effect.acquireRelease(
199 Effect.sync(() => new BunSqlite(path)),
200 (raw) => Effect.sync(() => raw.close()),
201 );
202 const accounts = yield* Effect.sync(() =>
203 raw
204 .query(
205 "SELECT id, accountId, providerId, userId, password FROM account ORDER BY id",
206 )
207 .all(),
208 );
209 expect(accounts).toEqual(legacy.accounts);
210 const columns = yield* Effect.sync(() =>
211 raw.query<{ name: string }, []>("PRAGMA table_info(account)").all(),
212 );
213 expect(columns.map((column) => column.name)).not.toContain("issuer");
214 }).pipe(Effect.scoped);
215
216 const signUp = yield* auth.api.signUpEmail({
217 body: {
218 email: "new@example.com",
219 password: "password1234",
220 name: "New User",
221 },
222 });
223 expect(signUp.user.id).not.toBe(legacy.userId);
224 expect(yield* applyMigrations(database.migrate!, baseOptions)).toEqual({
225 tablesCreated: 0,
226 tablesAltered: 0,
227 indexesCreated: 0,
228 });
229 }).pipe(provideTestEnv),
230 );
231
232 it.live("reports unsafe populated-table changes as migration errors", () =>
233 Effect.gen(function* () {
234 const path = yield* tempSqlitePath;
235 const layer = SQLite(path);
236 const database = yield* Database.pipe(Effect.provide(layer));
237 yield* applyMigrations(database.migrate!, baseOptions);
238 const auth = yield* BetterAuth(baseOptions).pipe(Effect.provide(layer));
239 yield* auth.api.signUpEmail({
240 body: {
241 email: "unsafe@example.com",
242 password: "password1234",
243 name: "Existing User",
244 },
245 });
246 const result = yield* Effect.result(
247 applyMigrations(database.migrate!, {
248 ...baseOptions,
249 user: {
250 additionalFields: { team: { type: "string", required: true } },
251 },
252 }),
253 );
254 expect(Result.isFailure(result)).toBe(true);
255 if (Result.isFailure(result)) {
256 expect(result.failure).toBeInstanceOf(BetterAuthMigrationError);
257 expect(result.failure.message).toContain("team");
258 }
259 }).pipe(provideTestEnv),
260 );
261
262 it.live("schema fingerprint changes when only a table index changes", () =>
263 Effect.gen(function* () {
264 const withIndex = (unique: boolean) => ({
265 ...baseOptions,
266 plugins: [
267 {
268 id: "account-index",
269 schema: {
270 account: {
271 fields: {},
272 indexes: [
273 {
274 name: "provider_account",
275 fields: ["providerId", "accountId"],
276 unique,
277 },
278 ],
279 },
280 },
281 },
282 ],
283 });
284 expect(yield* schemaFingerprint(withIndex(false))).not.toBe(
285 yield* schemaFingerprint(withIndex(true)),
286 );
287 }).pipe(provideTestEnv),
288 );
289
290 it.live("schema fingerprint is stable and plugin-sensitive", () =>
291 Effect.gen(function* () {
292 const a = yield* schemaFingerprint(baseOptions);
293 const b = yield* schemaFingerprint(baseOptions);
294 expect(a).toBe(b);
295 const withPlugin = yield* schemaFingerprint({
296 ...baseOptions,
297 plugins: [organization()],
298 });
299 expect(withPlugin).not.toBe(a);
300 }).pipe(provideTestEnv),
301 );
302});