experiments in a post-browser web
at main 451 lines 15 kB view raw
1/** 2 * API Integration Tests 3 * 4 * Setup: Copy .env.example to .env and fill in your API keys 5 * 6 * Run against local server: 7 * npm run test:api:local 8 * 9 * Run against production: 10 * npm run test:api:prod 11 */ 12 13const { test, describe, before, after } = require("node:test"); 14const assert = require("node:assert"); 15 16// Determine environment from command line args 17const args = process.argv.slice(2); 18const isLocal = args.includes("--local"); 19const isProd = args.includes("--prod"); 20 21let BASE_URL, API_KEY; 22 23if (isLocal) { 24 BASE_URL = "http://localhost:3000"; 25 API_KEY = process.env.PEEK_LOCAL_KEY; 26} else if (isProd) { 27 BASE_URL = process.env.PEEK_PROD_URL; 28 if (!BASE_URL) { 29 console.error("ERROR: PEEK_PROD_URL environment variable is required for prod mode"); 30 process.exit(1); 31 } 32 API_KEY = process.env.PEEK_PROD_KEY; 33} else { 34 // Fallback to legacy env vars for backwards compatibility 35 BASE_URL = process.env.BASE_URL || "http://localhost:3000"; 36 API_KEY = process.env.API_KEY; 37} 38 39if (!API_KEY) { 40 console.error("ERROR: API key not found"); 41 console.error("Setup: Copy .env.example to .env and fill in your keys"); 42 console.error(" npm run test:api:local (uses PEEK_LOCAL_KEY)"); 43 console.error(" npm run test:api:prod (uses PEEK_PROD_KEY)"); 44 process.exit(1); 45} 46 47const headers = { 48 Authorization: `Bearer ${API_KEY}`, 49 "Content-Type": "application/json", 50}; 51 52async function api(method, path, body = null) { 53 const opts = { method, headers }; 54 if (body) { 55 opts.body = JSON.stringify(body); 56 } 57 const res = await fetch(`${BASE_URL}${path}`, opts); 58 const data = await res.json(); 59 return { status: res.status, data }; 60} 61 62// Track created items for cleanup 63const createdItems = []; 64 65describe("API Integration Tests", () => { 66 describe("Health Check", () => { 67 test("GET / returns ok without auth", async () => { 68 const res = await fetch(`${BASE_URL}/`); 69 const data = await res.json(); 70 assert.strictEqual(res.status, 200); 71 assert.strictEqual(data.status, "ok"); 72 }); 73 }); 74 75 describe("Authentication", () => { 76 test("returns 401 without auth header", async () => { 77 const res = await fetch(`${BASE_URL}/urls`); 78 assert.strictEqual(res.status, 401); 79 }); 80 81 test("returns 401 with invalid key", async () => { 82 const res = await fetch(`${BASE_URL}/urls`, { 83 headers: { Authorization: "Bearer invalid-key" }, 84 }); 85 assert.strictEqual(res.status, 401); 86 }); 87 88 test("returns 200 with valid key", async () => { 89 const { status } = await api("GET", "/urls"); 90 assert.strictEqual(status, 200); 91 }); 92 }); 93 94 describe("URLs (legacy endpoints)", () => { 95 let urlId; 96 97 test("POST /webhook saves URLs", async () => { 98 const { status, data } = await api("POST", "/webhook", { 99 urls: [{ url: "https://test-api-url.example.com", tags: ["test", "api"] }], 100 }); 101 assert.strictEqual(status, 200); 102 assert.strictEqual(data.received, true); 103 assert.strictEqual(data.saved_count, 1); 104 }); 105 106 test("GET /urls returns saved URLs", async () => { 107 const { status, data } = await api("GET", "/urls"); 108 assert.strictEqual(status, 200); 109 assert.ok(Array.isArray(data.urls)); 110 const found = data.urls.find((u) => u.url === "https://test-api-url.example.com"); 111 assert.ok(found, "Should find the saved URL"); 112 assert.deepStrictEqual(found.tags, ["test", "api"]); 113 urlId = found.id; 114 }); 115 116 test("PATCH /urls/:id/tags updates tags", async () => { 117 const { status, data } = await api("PATCH", `/urls/${urlId}/tags`, { 118 tags: ["updated", "tags"], 119 }); 120 assert.strictEqual(status, 200); 121 assert.strictEqual(data.updated, true); 122 }); 123 124 test("DELETE /urls/:id deletes URL", async () => { 125 const { status, data } = await api("DELETE", `/urls/${urlId}`); 126 assert.strictEqual(status, 200); 127 assert.strictEqual(data.deleted, true); 128 }); 129 }); 130 131 describe("Texts", () => { 132 let textId; 133 134 test("POST /texts creates text", async () => { 135 const { status, data } = await api("POST", "/texts", { 136 content: "Test note from API tests", 137 tags: ["note", "test"], 138 }); 139 assert.strictEqual(status, 200); 140 assert.ok(data.id); 141 assert.strictEqual(data.created, true); 142 textId = data.id; 143 createdItems.push({ type: "text", id: textId }); 144 }); 145 146 test("POST /texts requires content", async () => { 147 const { status, data } = await api("POST", "/texts", { tags: ["test"] }); 148 assert.strictEqual(status, 400); 149 assert.ok(data.error); 150 }); 151 152 test("GET /texts returns texts", async () => { 153 const { status, data } = await api("GET", "/texts"); 154 assert.strictEqual(status, 200); 155 assert.ok(Array.isArray(data.texts)); 156 const found = data.texts.find((t) => t.id === textId); 157 assert.ok(found, "Should find created text"); 158 assert.strictEqual(found.content, "Test note from API tests"); 159 }); 160 161 test("PATCH /texts/:id/tags updates tags", async () => { 162 const { status, data } = await api("PATCH", `/texts/${textId}/tags`, { 163 tags: ["updated"], 164 }); 165 assert.strictEqual(status, 200); 166 assert.strictEqual(data.updated, true); 167 }); 168 169 test("DELETE /texts/:id deletes text", async () => { 170 const { status, data } = await api("DELETE", `/texts/${textId}`); 171 assert.strictEqual(status, 200); 172 assert.strictEqual(data.deleted, true); 173 createdItems.pop(); // Remove from cleanup list 174 }); 175 }); 176 177 describe("Tagsets", () => { 178 let tagsetId; 179 180 test("POST /tagsets creates tagset", async () => { 181 const { status, data } = await api("POST", "/tagsets", { 182 tags: ["pushups", "10"], 183 }); 184 assert.strictEqual(status, 200); 185 assert.ok(data.id); 186 assert.strictEqual(data.created, true); 187 tagsetId = data.id; 188 createdItems.push({ type: "tagset", id: tagsetId }); 189 }); 190 191 test("POST /tagsets requires tags", async () => { 192 const { status, data } = await api("POST", "/tagsets", {}); 193 assert.strictEqual(status, 400); 194 assert.ok(data.error); 195 }); 196 197 test("GET /tagsets returns tagsets", async () => { 198 const { status, data } = await api("GET", "/tagsets"); 199 assert.strictEqual(status, 200); 200 assert.ok(Array.isArray(data.tagsets)); 201 const found = data.tagsets.find((t) => t.id === tagsetId); 202 assert.ok(found, "Should find created tagset"); 203 assert.deepStrictEqual(found.tags, ["pushups", "10"]); 204 }); 205 206 test("PATCH /tagsets/:id/tags updates tags", async () => { 207 const { status, data } = await api("PATCH", `/tagsets/${tagsetId}/tags`, { 208 tags: ["squats", "15"], 209 }); 210 assert.strictEqual(status, 200); 211 assert.strictEqual(data.updated, true); 212 }); 213 214 test("DELETE /tagsets/:id deletes tagset", async () => { 215 const { status, data } = await api("DELETE", `/tagsets/${tagsetId}`); 216 assert.strictEqual(status, 200); 217 assert.strictEqual(data.deleted, true); 218 createdItems.pop(); 219 }); 220 }); 221 222 describe("Unified Items API", () => { 223 let urlItemId, textItemId, tagsetItemId; 224 225 test("POST /items creates URL item", async () => { 226 const { status, data } = await api("POST", "/items", { 227 type: "url", 228 content: "https://unified-test.example.com", 229 tags: ["unified", "url"], 230 }); 231 assert.strictEqual(status, 200); 232 assert.ok(data.id); 233 assert.strictEqual(data.type, "url"); 234 urlItemId = data.id; 235 createdItems.push({ type: "item", id: urlItemId }); 236 }); 237 238 test("POST /items creates text item", async () => { 239 const { status, data } = await api("POST", "/items", { 240 type: "text", 241 content: "Unified text content", 242 tags: ["unified", "text"], 243 }); 244 assert.strictEqual(status, 200); 245 assert.ok(data.id); 246 assert.strictEqual(data.type, "text"); 247 textItemId = data.id; 248 createdItems.push({ type: "item", id: textItemId }); 249 }); 250 251 test("POST /items creates tagset item", async () => { 252 const { status, data } = await api("POST", "/items", { 253 type: "tagset", 254 tags: ["unified", "tagset"], 255 }); 256 assert.strictEqual(status, 200); 257 assert.ok(data.id); 258 assert.strictEqual(data.type, "tagset"); 259 tagsetItemId = data.id; 260 createdItems.push({ type: "item", id: tagsetItemId }); 261 }); 262 263 test("POST /items validates type", async () => { 264 const { status, data } = await api("POST", "/items", { 265 type: "invalid", 266 content: "test", 267 }); 268 assert.strictEqual(status, 400); 269 assert.ok(data.error); 270 }); 271 272 test("GET /items returns all items", async () => { 273 const { status, data } = await api("GET", "/items"); 274 assert.strictEqual(status, 200); 275 assert.ok(Array.isArray(data.items)); 276 assert.ok(data.items.length >= 3); 277 }); 278 279 test("GET /items?type=url filters by type", async () => { 280 const { status, data } = await api("GET", "/items?type=url"); 281 assert.strictEqual(status, 200); 282 assert.ok(Array.isArray(data.items)); 283 assert.ok(data.items.every((i) => i.type === "url")); 284 }); 285 286 test("GET /items?type=text filters by type", async () => { 287 const { status, data } = await api("GET", "/items?type=text"); 288 assert.strictEqual(status, 200); 289 assert.ok(data.items.every((i) => i.type === "text")); 290 }); 291 292 test("GET /items?type=tagset filters by type", async () => { 293 const { status, data } = await api("GET", "/items?type=tagset"); 294 assert.strictEqual(status, 200); 295 assert.ok(data.items.every((i) => i.type === "tagset")); 296 }); 297 298 test("PATCH /items/:id/tags updates tags", async () => { 299 const { status, data } = await api("PATCH", `/items/${urlItemId}/tags`, { 300 tags: ["modified"], 301 }); 302 assert.strictEqual(status, 200); 303 assert.strictEqual(data.updated, true); 304 }); 305 306 test("DELETE /items/:id deletes item", async () => { 307 // Clean up all created items 308 for (const item of [urlItemId, textItemId, tagsetItemId]) { 309 const { status, data } = await api("DELETE", `/items/${item}`); 310 assert.strictEqual(status, 200); 311 assert.strictEqual(data.deleted, true); 312 } 313 // Clear from cleanup list 314 createdItems.splice(-3); 315 }); 316 }); 317 318 describe("Tags", () => { 319 test("GET /tags returns tags sorted by frecency", async () => { 320 const { status, data } = await api("GET", "/tags"); 321 assert.strictEqual(status, 200); 322 assert.ok(Array.isArray(data.tags)); 323 // Tags should have frecency_score 324 if (data.tags.length > 0) { 325 assert.ok("frecency_score" in data.tags[0]); 326 assert.ok("frequency" in data.tags[0]); 327 } 328 }); 329 }); 330 331 describe("Images", () => { 332 // 1x1 PNG image as base64 333 const testPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; 334 let imageId; 335 336 test("POST /images with JSON base64 creates image", async () => { 337 const { status, data } = await api("POST", "/images", { 338 content: testPngBase64, 339 filename: "test-api.png", 340 mime: "image/png", 341 tags: ["test", "api"], 342 }); 343 assert.strictEqual(status, 200); 344 assert.ok(data.id); 345 assert.strictEqual(data.type, "image"); 346 assert.strictEqual(data.created, true); 347 imageId = data.id; 348 createdItems.push({ type: "image", id: imageId }); 349 }); 350 351 test("POST /images validates mime type", async () => { 352 const { status, data } = await api("POST", "/images", { 353 content: testPngBase64, 354 filename: "test.txt", 355 mime: "text/plain", 356 tags: [], 357 }); 358 assert.strictEqual(status, 400); 359 assert.ok(data.error); 360 }); 361 362 test("POST /images requires filename", async () => { 363 const { status, data } = await api("POST", "/images", { 364 content: testPngBase64, 365 mime: "image/png", 366 }); 367 assert.strictEqual(status, 400); 368 assert.ok(data.error); 369 }); 370 371 test("GET /images returns images", async () => { 372 const { status, data } = await api("GET", "/images"); 373 assert.strictEqual(status, 200); 374 assert.ok(Array.isArray(data.images)); 375 const found = data.images.find((i) => i.id === imageId); 376 assert.ok(found, "Should find created image"); 377 assert.strictEqual(found.filename, "test-api.png"); 378 assert.strictEqual(found.mime, "image/png"); 379 assert.ok(found.size > 0); 380 }); 381 382 test("GET /images/:id returns image binary", async () => { 383 const res = await fetch(`${BASE_URL}/images/${imageId}`, { 384 headers: { Authorization: `Bearer ${API_KEY}` }, 385 }); 386 assert.strictEqual(res.status, 200); 387 assert.strictEqual(res.headers.get("Content-Type"), "image/png"); 388 const buffer = await res.arrayBuffer(); 389 assert.ok(buffer.byteLength > 0); 390 }); 391 392 test("GET /images/:id returns 404 for non-existent", async () => { 393 const res = await fetch(`${BASE_URL}/images/non-existent-id`, { 394 headers: { Authorization: `Bearer ${API_KEY}` }, 395 }); 396 assert.strictEqual(res.status, 404); 397 }); 398 399 test("PATCH /images/:id/tags updates tags", async () => { 400 const { status, data } = await api("PATCH", `/images/${imageId}/tags`, { 401 tags: ["updated", "image"], 402 }); 403 assert.strictEqual(status, 200); 404 assert.strictEqual(data.updated, true); 405 }); 406 407 test("GET /items?type=image filters images", async () => { 408 const { status, data } = await api("GET", "/items?type=image"); 409 assert.strictEqual(status, 200); 410 assert.ok(data.items.every((i) => i.type === "image")); 411 const found = data.items.find((i) => i.id === imageId); 412 assert.ok(found, "Should find image in items"); 413 assert.ok(found.metadata); 414 assert.strictEqual(found.metadata.mime, "image/png"); 415 }); 416 417 test("POST /items with type=image works", async () => { 418 const { status, data } = await api("POST", "/items", { 419 type: "image", 420 content: testPngBase64, 421 filename: "unified-image.png", 422 mime: "image/png", 423 tags: ["unified"], 424 }); 425 assert.strictEqual(status, 200); 426 assert.ok(data.id); 427 assert.strictEqual(data.type, "image"); 428 createdItems.push({ type: "image", id: data.id }); 429 }); 430 431 test("DELETE /images/:id deletes image", async () => { 432 const { status, data } = await api("DELETE", `/images/${imageId}`); 433 assert.strictEqual(status, 200); 434 assert.strictEqual(data.deleted, true); 435 createdItems.shift(); // Remove first image from cleanup 436 }); 437 }); 438 439 // Cleanup any remaining items 440 after(async () => { 441 for (const item of createdItems) { 442 try { 443 await api("DELETE", `/items/${item.id}`); 444 } catch (e) { 445 // Ignore cleanup errors 446 } 447 } 448 }); 449}); 450 451console.log(`\nRunning API tests against: ${BASE_URL}\n`);