experiments in a post-browser web
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`);