Storage implementations for AT Protocol OAuth applications. Provides a simple key-value storage interface with implementations for in-memory and SQLite backends.
at main 392 lines 12 kB view raw
1import { assertEquals, assertExists } from "@std/assert"; 2import { MemoryStorage } from "./memory.ts"; 3import { SQLiteStorage } from "./sqlite.ts"; 4import { sqliteAdapter } from "./adapters.ts"; 5import type { SQLiteAdapter } from "./types.ts"; 6 7// Mock SQLite database that implements the ExecutableDriver interface 8// (used with sqliteAdapter to create an SQLiteAdapter) 9class MockExecutableDriver { 10 private tables = new Map<string, Map<string, unknown[]>>(); 11 12 execute( 13 query: { sql: string; args: unknown[] }, 14 ): Promise<{ rows: unknown[][] }> { 15 const sql = query.sql.trim(); 16 17 // CREATE TABLE 18 if (sql.toUpperCase().startsWith("CREATE TABLE")) { 19 const match = sql.match(/CREATE TABLE IF NOT EXISTS (\w+)/i); 20 if (match) { 21 const tableName = match[1]; 22 if (!this.tables.has(tableName)) { 23 this.tables.set(tableName, new Map()); 24 } 25 } 26 return Promise.resolve({ rows: [] }); 27 } 28 29 // CREATE INDEX 30 if (sql.toUpperCase().startsWith("CREATE INDEX")) { 31 return Promise.resolve({ rows: [] }); 32 } 33 34 // INSERT 35 if (sql.toUpperCase().startsWith("INSERT")) { 36 const match = sql.match(/INSERT INTO (\w+)/i); 37 if (match) { 38 const tableName = match[1]; 39 const table = this.tables.get(tableName) || new Map(); 40 const key = query.args[0] as string; 41 table.set(key, query.args); 42 this.tables.set(tableName, table); 43 } 44 return Promise.resolve({ rows: [] }); 45 } 46 47 // SELECT 48 if (sql.toUpperCase().startsWith("SELECT")) { 49 const countMatch = sql.match(/SELECT COUNT\(\*\) FROM (\w+)/i); 50 if (countMatch) { 51 return Promise.resolve({ rows: [[0]] }); 52 } 53 54 const match = sql.match(/FROM (\w+)/i); 55 if (match) { 56 const tableName = match[1]; 57 const table = this.tables.get(tableName); 58 if (table) { 59 const key = query.args[0] as string; 60 const row = table.get(key); 61 if (row) { 62 // Return value and expires_at (indices 1 and 2) 63 return Promise.resolve({ 64 rows: [[row[1], row[2]]], 65 }); 66 } 67 } 68 } 69 return Promise.resolve({ rows: [] }); 70 } 71 72 // DELETE 73 if (sql.toUpperCase().startsWith("DELETE")) { 74 const match = sql.match(/FROM (\w+)/i); 75 if (match) { 76 const tableName = match[1]; 77 const table = this.tables.get(tableName); 78 if (table) { 79 const key = query.args[0] as string; 80 table.delete(key); 81 } 82 } 83 return Promise.resolve({ rows: [] }); 84 } 85 86 return Promise.resolve({ rows: [] }); 87 } 88} 89 90// Direct mock adapter for testing (without going through valTownAdapter) 91function createMockAdapter(): SQLiteAdapter { 92 const tables = new Map<string, Map<string, unknown[]>>(); 93 94 return { 95 execute: (sql: string, params: unknown[]): Promise<unknown[][]> => { 96 const trimmedSql = sql.trim(); 97 98 // CREATE TABLE 99 if (trimmedSql.toUpperCase().startsWith("CREATE TABLE")) { 100 const match = trimmedSql.match(/CREATE TABLE IF NOT EXISTS (\w+)/i); 101 if (match) { 102 const tableName = match[1]; 103 if (!tables.has(tableName)) { 104 tables.set(tableName, new Map()); 105 } 106 } 107 return Promise.resolve([]); 108 } 109 110 // CREATE INDEX 111 if (trimmedSql.toUpperCase().startsWith("CREATE INDEX")) { 112 return Promise.resolve([]); 113 } 114 115 // INSERT 116 if (trimmedSql.toUpperCase().startsWith("INSERT")) { 117 const match = trimmedSql.match(/INSERT INTO (\w+)/i); 118 if (match) { 119 const tableName = match[1]; 120 const table = tables.get(tableName) || new Map(); 121 const key = params[0] as string; 122 table.set(key, params); 123 tables.set(tableName, table); 124 } 125 return Promise.resolve([]); 126 } 127 128 // SELECT 129 if (trimmedSql.toUpperCase().startsWith("SELECT")) { 130 const countMatch = trimmedSql.match(/SELECT COUNT\(\*\) FROM (\w+)/i); 131 if (countMatch) { 132 return Promise.resolve([[0]]); 133 } 134 135 const match = trimmedSql.match(/FROM (\w+)/i); 136 if (match) { 137 const tableName = match[1]; 138 const table = tables.get(tableName); 139 if (table) { 140 const key = params[0] as string; 141 const row = table.get(key); 142 if (row) { 143 // Return value and expires_at (indices 1 and 2) 144 return Promise.resolve([[row[1], row[2]]]); 145 } 146 } 147 } 148 return Promise.resolve([]); 149 } 150 151 // DELETE 152 if (trimmedSql.toUpperCase().startsWith("DELETE")) { 153 const match = trimmedSql.match(/FROM (\w+)/i); 154 if (match) { 155 const tableName = match[1]; 156 const table = tables.get(tableName); 157 if (table) { 158 const key = params[0] as string; 159 table.delete(key); 160 } 161 } 162 return Promise.resolve([]); 163 } 164 165 return Promise.resolve([]); 166 }, 167 }; 168} 169 170// ============ MemoryStorage Tests ============ 171 172Deno.test("MemoryStorage - basic operations", async (t) => { 173 const storage = new MemoryStorage(); 174 175 await t.step("set and get value", async () => { 176 await storage.set("key1", { foo: "bar" }); 177 const result = await storage.get<{ foo: string }>("key1"); 178 assertExists(result); 179 assertEquals(result.foo, "bar"); 180 }); 181 182 await t.step("get non-existent key returns null", async () => { 183 const result = await storage.get("nonexistent"); 184 assertEquals(result, null); 185 }); 186 187 await t.step("delete removes value", async () => { 188 await storage.set("key2", "value"); 189 await storage.delete("key2"); 190 const result = await storage.get("key2"); 191 assertEquals(result, null); 192 }); 193 194 await t.step("overwrite existing value", async () => { 195 await storage.set("key3", "first"); 196 await storage.set("key3", "second"); 197 const result = await storage.get("key3"); 198 assertEquals(result, "second"); 199 }); 200}); 201 202Deno.test("MemoryStorage - TTL expiration", async (t) => { 203 const storage = new MemoryStorage(); 204 205 await t.step("value available before TTL", async () => { 206 await storage.set("ttl-key", "value", { ttl: 10 }); // 10 seconds 207 const result = await storage.get("ttl-key"); 208 assertEquals(result, "value"); 209 }); 210 211 await t.step("value expired after TTL", async () => { 212 // Set with very short TTL 213 await storage.set("expired-key", "value", { ttl: 0.001 }); // 1ms 214 // Wait long enough to ensure expiration 215 await new Promise((r) => setTimeout(r, 50)); 216 const result = await storage.get("expired-key"); 217 assertEquals(result, null); 218 }); 219 220 await t.step("value without TTL never expires", async () => { 221 await storage.set("no-ttl", "value"); 222 const result = await storage.get("no-ttl"); 223 assertEquals(result, "value"); 224 }); 225}); 226 227Deno.test("MemoryStorage - helper methods", async (t) => { 228 await t.step("clear removes all entries", async () => { 229 const storage = new MemoryStorage(); 230 await storage.set("a", 1); 231 await storage.set("b", 2); 232 assertEquals(storage.size, 2); 233 234 storage.clear(); 235 assertEquals(storage.size, 0); 236 }); 237 238 await t.step("size reflects entry count", async () => { 239 const storage = new MemoryStorage(); 240 assertEquals(storage.size, 0); 241 242 await storage.set("a", 1); 243 assertEquals(storage.size, 1); 244 245 await storage.set("b", 2); 246 assertEquals(storage.size, 2); 247 248 await storage.delete("a"); 249 assertEquals(storage.size, 1); 250 }); 251}); 252 253Deno.test("MemoryStorage - complex values", async (t) => { 254 const storage = new MemoryStorage(); 255 256 await t.step("stores objects", async () => { 257 const obj = { nested: { deep: { value: 123 } } }; 258 await storage.set("obj", obj); 259 const result = await storage.get<typeof obj>("obj"); 260 assertEquals(result, obj); 261 }); 262 263 await t.step("stores arrays", async () => { 264 const arr = [1, 2, 3, { four: 4 }]; 265 await storage.set("arr", arr); 266 const result = await storage.get<typeof arr>("arr"); 267 assertEquals(result, arr); 268 }); 269 270 await t.step("stores null", async () => { 271 await storage.set("null", null); 272 const result = await storage.get("null"); 273 assertEquals(result, null); 274 }); 275}); 276 277// ============ SQLiteStorage Tests ============ 278 279Deno.test("SQLiteStorage - basic operations with direct adapter", async (t) => { 280 const adapter = createMockAdapter(); 281 const storage = new SQLiteStorage(adapter); 282 283 await t.step("set and get value", async () => { 284 await storage.set("key1", { foo: "bar" }); 285 const result = await storage.get<{ foo: string }>("key1"); 286 assertExists(result); 287 assertEquals(result.foo, "bar"); 288 }); 289 290 await t.step("get non-existent key returns null", async () => { 291 const result = await storage.get("nonexistent"); 292 assertEquals(result, null); 293 }); 294 295 await t.step("delete removes value", async () => { 296 await storage.set("key2", "value"); 297 await storage.delete("key2"); 298 const result = await storage.get("key2"); 299 assertEquals(result, null); 300 }); 301}); 302 303Deno.test("SQLiteStorage - with sqliteAdapter", async (t) => { 304 const mockDriver = new MockExecutableDriver(); 305 const adapter = sqliteAdapter(mockDriver); 306 const storage = new SQLiteStorage(adapter); 307 308 await t.step("set and get value", async () => { 309 await storage.set("key1", { foo: "bar" }); 310 const result = await storage.get<{ foo: string }>("key1"); 311 assertExists(result); 312 assertEquals(result.foo, "bar"); 313 }); 314 315 await t.step("get non-existent key returns null", async () => { 316 const result = await storage.get("nonexistent"); 317 assertEquals(result, null); 318 }); 319 320 await t.step("delete removes value", async () => { 321 await storage.set("key2", "value"); 322 await storage.delete("key2"); 323 const result = await storage.get("key2"); 324 assertEquals(result, null); 325 }); 326}); 327 328Deno.test("SQLiteStorage - custom options", async (t) => { 329 await t.step("accepts custom table name", async () => { 330 const adapter = createMockAdapter(); 331 const storage = new SQLiteStorage(adapter, { tableName: "custom_table" }); 332 await storage.set("key", "value"); 333 const result = await storage.get("key"); 334 assertEquals(result, "value"); 335 }); 336 337 await t.step("accepts custom logger", async () => { 338 const logs: string[] = []; 339 const logger = { 340 log: (...args: unknown[]) => logs.push(args.join(" ")), 341 warn: () => {}, 342 error: () => {}, 343 }; 344 345 const adapter = createMockAdapter(); 346 const storage = new SQLiteStorage(adapter, { logger }); 347 await storage.set("key", "value"); 348 349 assertEquals(logs.length > 0, true); 350 }); 351}); 352 353Deno.test("SQLiteStorage - TTL handling", async (t) => { 354 const adapter = createMockAdapter(); 355 const storage = new SQLiteStorage(adapter); 356 357 await t.step("sets TTL when provided", async () => { 358 await storage.set("ttl-key", "value", { ttl: 3600 }); 359 const result = await storage.get("ttl-key"); 360 assertEquals(result, "value"); 361 }); 362 363 await t.step("no TTL when not provided", async () => { 364 await storage.set("no-ttl", "value"); 365 const result = await storage.get("no-ttl"); 366 assertEquals(result, "value"); 367 }); 368}); 369 370// ============ Adapter Tests ============ 371 372Deno.test("sqliteAdapter - transforms execute signature", async () => { 373 let capturedSql = ""; 374 let capturedParams: unknown[] = []; 375 376 const mockDriver = { 377 execute: (query: { sql: string; args: unknown[] }) => { 378 capturedSql = query.sql; 379 capturedParams = query.args; 380 return Promise.resolve({ rows: [["test-value", null]] }); 381 }, 382 }; 383 384 const adapter = sqliteAdapter(mockDriver); 385 const result = await adapter.execute("SELECT * FROM test WHERE id = ?", [ 386 123, 387 ]); 388 389 assertEquals(capturedSql, "SELECT * FROM test WHERE id = ?"); 390 assertEquals(capturedParams, [123]); 391 assertEquals(result, [["test-value", null]]); 392});