Openstatus
www.openstatus.dev
1import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2import { and, db, eq, isNotNull, isNull } from "@openstatus/db";
3import { page, pageSubscriber, workspace } from "@openstatus/db/src/schema";
4
5/**
6 * End-to-end integration tests for the full unsubscribe flow.
7 * These tests simulate the complete user journey:
8 * subscribe -> verify -> receive email -> unsubscribe
9 */
10
11let testPageId: number;
12let testWorkspaceId: number;
13const testSlug = "e2e-unsubscribe-test-page";
14const testEmail = "e2e-test-user@example.com";
15let subscriberToken: string;
16
17beforeAll(async () => {
18 // Clean up any existing test data
19 await db.delete(pageSubscriber).where(eq(pageSubscriber.email, testEmail));
20 await db.delete(page).where(eq(page.slug, testSlug));
21
22 // Get an existing workspace (use workspace id 1 from seed data)
23 const existingWorkspace = await db.query.workspace.findFirst({
24 where: eq(workspace.id, 1),
25 });
26
27 if (!existingWorkspace) {
28 throw new Error(
29 "Test workspace not found. Please ensure seed data exists.",
30 );
31 }
32
33 testWorkspaceId = existingWorkspace.id;
34
35 // Create a test page
36 const testPage = await db
37 .insert(page)
38 .values({
39 workspaceId: testWorkspaceId,
40 title: "E2E Test Status Page",
41 description: "A test page for E2E unsubscribe flow tests",
42 slug: testSlug,
43 customDomain: "",
44 })
45 .returning()
46 .get();
47
48 testPageId = testPage.id;
49});
50
51afterAll(async () => {
52 // Clean up test data
53 await db.delete(pageSubscriber).where(eq(pageSubscriber.email, testEmail));
54 await db.delete(page).where(eq(page.slug, testSlug));
55});
56
57describe("Full unsubscribe flow: subscribe -> verify -> unsubscribe", () => {
58 test("Step 1: User subscribes to status page", async () => {
59 // Simulate subscription by inserting a subscriber
60 const subscriber = await db
61 .insert(pageSubscriber)
62 .values({
63 pageId: testPageId,
64 email: testEmail,
65 token: crypto.randomUUID(),
66 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
67 })
68 .returning()
69 .get();
70
71 expect(subscriber.id).toBeDefined();
72 expect(subscriber.email).toBe(testEmail);
73 expect(subscriber.token).toBeDefined();
74 expect(subscriber.acceptedAt).toBeNull();
75 expect(subscriber.unsubscribedAt).toBeNull();
76
77 if (!subscriber.token) {
78 throw new Error("Subscriber token is undefined");
79 }
80
81 subscriberToken = subscriber.token;
82 });
83
84 test("Step 2: User verifies their email subscription", async () => {
85 // Verify the subscription
86 await db
87 .update(pageSubscriber)
88 .set({ acceptedAt: new Date() })
89 .where(eq(pageSubscriber.token, subscriberToken));
90
91 // Verify the subscription is now active
92 const subscriber = await db.query.pageSubscriber.findFirst({
93 where: eq(pageSubscriber.token, subscriberToken),
94 });
95
96 expect(subscriber?.acceptedAt).not.toBeNull();
97 expect(subscriber?.unsubscribedAt).toBeNull();
98 });
99
100 test("Step 3: Verified subscriber is included in email recipient list", async () => {
101 // This query mirrors the exact query used in statusReports/post.ts
102 const subscribers = await db
103 .select()
104 .from(pageSubscriber)
105 .where(
106 and(
107 eq(pageSubscriber.pageId, testPageId),
108 isNotNull(pageSubscriber.acceptedAt),
109 isNull(pageSubscriber.unsubscribedAt),
110 ),
111 )
112 .all();
113
114 expect(subscribers.length).toBe(1);
115 expect(subscribers[0].email).toBe(testEmail);
116 expect(subscribers[0].token).toBe(subscriberToken);
117 });
118
119 test("Step 4: User clicks unsubscribe and sets unsubscribedAt", async () => {
120 // Simulate the unsubscribe action
121 await db
122 .update(pageSubscriber)
123 .set({ unsubscribedAt: new Date() })
124 .where(eq(pageSubscriber.token, subscriberToken));
125
126 // Verify the unsubscription
127 const subscriber = await db.query.pageSubscriber.findFirst({
128 where: eq(pageSubscriber.token, subscriberToken),
129 });
130
131 expect(subscriber?.unsubscribedAt).not.toBeNull();
132 expect(subscriber?.unsubscribedAt).toBeInstanceOf(Date);
133 });
134
135 test("Step 5: Unsubscribed user is excluded from email recipient list", async () => {
136 // This query mirrors the exact query used in statusReports/post.ts
137 const subscribers = await db
138 .select()
139 .from(pageSubscriber)
140 .where(
141 and(
142 eq(pageSubscriber.pageId, testPageId),
143 isNotNull(pageSubscriber.acceptedAt),
144 isNull(pageSubscriber.unsubscribedAt),
145 ),
146 )
147 .all();
148
149 expect(subscribers.length).toBe(0);
150 });
151});
152
153describe("Confirmation page displays correct information", () => {
154 let confirmPageToken: string;
155
156 beforeAll(async () => {
157 // Create a fresh subscriber for confirmation page tests
158 await db
159 .delete(pageSubscriber)
160 .where(eq(pageSubscriber.email, "confirm-page-test@example.com"));
161
162 const subscriber = await db
163 .insert(pageSubscriber)
164 .values({
165 pageId: testPageId,
166 email: "confirm-page-test@example.com",
167 token: crypto.randomUUID(),
168 acceptedAt: new Date(), // Already verified
169 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
170 })
171 .returning()
172 .get();
173
174 if (!subscriber.token) {
175 throw new Error("Subscriber token is undefined");
176 }
177
178 confirmPageToken = subscriber.token;
179 });
180
181 afterAll(async () => {
182 await db
183 .delete(pageSubscriber)
184 .where(eq(pageSubscriber.email, "confirm-page-test@example.com"));
185 });
186
187 test("Confirmation page displays correct page name", async () => {
188 const subscriber = await db.query.pageSubscriber.findFirst({
189 where: eq(pageSubscriber.token, confirmPageToken),
190 with: {
191 page: true,
192 },
193 });
194
195 expect(subscriber?.page.title).toBe("E2E Test Status Page");
196 });
197
198 test("Confirmation page displays masked email (first char + *** + @domain)", async () => {
199 const subscriber = await db.query.pageSubscriber.findFirst({
200 where: eq(pageSubscriber.token, confirmPageToken),
201 });
202
203 if (!subscriber) {
204 throw new Error("Subscriber not found");
205 }
206
207 const email = subscriber.email;
208 expect(email).toBe("confirm-page-test@example.com");
209
210 // Apply the same masking logic as in the API
211 const [localPart, domain] = email.split("@");
212 const maskedEmail =
213 localPart.length > 0 ? `${localPart[0]}***@${domain}` : `***@${domain}`;
214
215 expect(maskedEmail).toBe("c***@example.com");
216 });
217
218 test("Email masking works for single character local part", async () => {
219 const email = "a@example.com";
220 const [localPart, domain] = email.split("@");
221 const maskedEmail =
222 localPart.length > 0 ? `${localPart[0]}***@${domain}` : `***@${domain}`;
223
224 expect(maskedEmail).toBe("a***@example.com");
225 });
226
227 test("Email masking works for long local part", async () => {
228 const email = "verylongemailaddress@example.com";
229 const [localPart, domain] = email.split("@");
230 const maskedEmail =
231 localPart.length > 0 ? `${localPart[0]}***@${domain}` : `***@${domain}`;
232
233 expect(maskedEmail).toBe("v***@example.com");
234 });
235});
236
237describe("Clicking confirm sets unsubscribedAt timestamp", () => {
238 let unsubscribeToken: string;
239
240 beforeAll(async () => {
241 // Create a fresh subscriber
242 await db
243 .delete(pageSubscriber)
244 .where(eq(pageSubscriber.email, "unsubscribe-click-test@example.com"));
245
246 const subscriber = await db
247 .insert(pageSubscriber)
248 .values({
249 pageId: testPageId,
250 email: "unsubscribe-click-test@example.com",
251 token: crypto.randomUUID(),
252 acceptedAt: new Date(), // Already verified
253 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
254 })
255 .returning()
256 .get();
257
258 if (!subscriber.token) {
259 throw new Error("Subscriber token is undefined");
260 }
261
262 unsubscribeToken = subscriber.token;
263 });
264
265 afterAll(async () => {
266 await db
267 .delete(pageSubscriber)
268 .where(eq(pageSubscriber.email, "unsubscribe-click-test@example.com"));
269 });
270
271 test("Before clicking confirm, unsubscribedAt is null", async () => {
272 const subscriber = await db.query.pageSubscriber.findFirst({
273 where: eq(pageSubscriber.token, unsubscribeToken),
274 });
275
276 expect(subscriber?.unsubscribedAt).toBeNull();
277 });
278
279 test("After clicking confirm, unsubscribedAt is set to current timestamp", async () => {
280 const beforeUnsubscribe = new Date();
281
282 // Simulate clicking "Confirm Unsubscribe"
283 await db
284 .update(pageSubscriber)
285 .set({ unsubscribedAt: new Date() })
286 .where(eq(pageSubscriber.token, unsubscribeToken));
287
288 const afterUnsubscribe = new Date();
289
290 const subscriber = await db.query.pageSubscriber.findFirst({
291 where: eq(pageSubscriber.token, unsubscribeToken),
292 });
293
294 if (!subscriber) {
295 throw new Error("Subscriber not found");
296 }
297
298 expect(subscriber.unsubscribedAt).not.toBeNull();
299 expect(subscriber.unsubscribedAt).toBeInstanceOf(Date);
300
301 // Verify the timestamp is within the expected range
302 if (!subscriber.unsubscribedAt) {
303 throw new Error("Subscriber unsubscribedAt is undefined");
304 }
305
306 // SQLite stores timestamps in seconds, so we compare at second precision
307 const unsubscribedTime = Math.floor(
308 subscriber.unsubscribedAt.getTime() / 1000,
309 );
310 const beforeTime = Math.floor(beforeUnsubscribe.getTime() / 1000);
311 const afterTime = Math.floor(afterUnsubscribe.getTime() / 1000);
312
313 expect(unsubscribedTime).toBeGreaterThanOrEqual(beforeTime);
314 expect(unsubscribedTime).toBeLessThanOrEqual(afterTime);
315 });
316
317 test("Subscriber state transitions correctly through the flow", async () => {
318 // Verify the subscriber has completed the full lifecycle
319 const subscriber = await db.query.pageSubscriber.findFirst({
320 where: eq(pageSubscriber.token, unsubscribeToken),
321 });
322
323 // Has been verified (acceptedAt is set)
324 expect(subscriber?.acceptedAt).not.toBeNull();
325
326 // Has been unsubscribed (unsubscribedAt is set)
327 expect(subscriber?.unsubscribedAt).not.toBeNull();
328
329 // Token is still present (for audit purposes)
330 expect(subscriber?.token).toBe(unsubscribeToken);
331 });
332});
333
334describe("Unsubscribed user does not receive new emails", () => {
335 let _activeToken: string;
336 let unsubscribedToken: string;
337 let pendingToken: string;
338
339 beforeAll(async () => {
340 // Clean up and create multiple subscribers with different states
341 await db
342 .delete(pageSubscriber)
343 .where(eq(pageSubscriber.email, "active-user@example.com"));
344 await db
345 .delete(pageSubscriber)
346 .where(eq(pageSubscriber.email, "unsubscribed-user@example.com"));
347 await db
348 .delete(pageSubscriber)
349 .where(eq(pageSubscriber.email, "pending-user@example.com"));
350
351 // Active subscriber
352 const active = await db
353 .insert(pageSubscriber)
354 .values({
355 pageId: testPageId,
356 email: "active-user@example.com",
357 token: crypto.randomUUID(),
358 acceptedAt: new Date(),
359 unsubscribedAt: null,
360 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
361 })
362 .returning()
363 .get();
364
365 if (!active.token) {
366 throw new Error("Active subscriber token is undefined");
367 }
368
369 _activeToken = active.token;
370
371 // Unsubscribed subscriber
372 const unsubscribed = await db
373 .insert(pageSubscriber)
374 .values({
375 pageId: testPageId,
376 email: "unsubscribed-user@example.com",
377 token: crypto.randomUUID(),
378 acceptedAt: new Date(),
379 unsubscribedAt: new Date(),
380 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
381 })
382 .returning()
383 .get();
384
385 if (!unsubscribed.token) {
386 throw new Error("Unsubscribed subscriber token is undefined");
387 }
388
389 unsubscribedToken = unsubscribed.token;
390
391 // Pending (unverified) subscriber
392 const pending = await db
393 .insert(pageSubscriber)
394 .values({
395 pageId: testPageId,
396 email: "pending-user@example.com",
397 token: crypto.randomUUID(),
398 acceptedAt: null,
399 unsubscribedAt: null,
400 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
401 })
402 .returning()
403 .get();
404
405 if (!pending.token) {
406 throw new Error("Pending subscriber token is undefined");
407 }
408
409 pendingToken = pending.token;
410 });
411
412 afterAll(async () => {
413 await db
414 .delete(pageSubscriber)
415 .where(eq(pageSubscriber.email, "active-user@example.com"));
416 await db
417 .delete(pageSubscriber)
418 .where(eq(pageSubscriber.email, "unsubscribed-user@example.com"));
419 await db
420 .delete(pageSubscriber)
421 .where(eq(pageSubscriber.email, "pending-user@example.com"));
422 });
423
424 test("Email query returns only active subscribers with valid tokens", async () => {
425 // This mirrors the exact query pattern used in email-sending routes
426 const emailRecipients = await db
427 .select({
428 email: pageSubscriber.email,
429 token: pageSubscriber.token,
430 })
431 .from(pageSubscriber)
432 .where(
433 and(
434 eq(pageSubscriber.pageId, testPageId),
435 isNotNull(pageSubscriber.acceptedAt),
436 isNull(pageSubscriber.unsubscribedAt),
437 ),
438 )
439 .all();
440
441 // Should only include active subscriber
442 expect(emailRecipients.length).toBeGreaterThanOrEqual(1);
443
444 const emails = emailRecipients.map((r) => r.email);
445 expect(emails).toContain("active-user@example.com");
446 expect(emails).not.toContain("unsubscribed-user@example.com");
447 expect(emails).not.toContain("pending-user@example.com");
448 });
449
450 test("Unsubscribed users are filtered out even with acceptedAt set", async () => {
451 // Verify the unsubscribed user has acceptedAt set
452 const unsubscribedUser = await db.query.pageSubscriber.findFirst({
453 where: eq(pageSubscriber.token, unsubscribedToken),
454 });
455
456 expect(unsubscribedUser?.acceptedAt).not.toBeNull();
457 expect(unsubscribedUser?.unsubscribedAt).not.toBeNull();
458
459 // Query with proper filters
460 const subscribers = await db
461 .select()
462 .from(pageSubscriber)
463 .where(
464 and(
465 eq(pageSubscriber.pageId, testPageId),
466 isNotNull(pageSubscriber.acceptedAt),
467 isNull(pageSubscriber.unsubscribedAt),
468 ),
469 )
470 .all();
471
472 const foundUnsubscribed = subscribers.find(
473 (s) => s.email === "unsubscribed-user@example.com",
474 );
475 expect(foundUnsubscribed).toBeUndefined();
476 });
477
478 test("Pending users are filtered out (not verified)", async () => {
479 // Verify the pending user has no acceptedAt
480 const pendingUser = await db.query.pageSubscriber.findFirst({
481 where: eq(pageSubscriber.token, pendingToken),
482 });
483
484 expect(pendingUser?.acceptedAt).toBeNull();
485
486 // Query with proper filters
487 const subscribers = await db
488 .select()
489 .from(pageSubscriber)
490 .where(
491 and(
492 eq(pageSubscriber.pageId, testPageId),
493 isNotNull(pageSubscriber.acceptedAt),
494 isNull(pageSubscriber.unsubscribedAt),
495 ),
496 )
497 .all();
498
499 const foundPending = subscribers.find(
500 (s) => s.email === "pending-user@example.com",
501 );
502 expect(foundPending).toBeUndefined();
503 });
504
505 test("Email recipients list includes token for unsubscribe URL generation", async () => {
506 const emailRecipients = await db
507 .select({
508 email: pageSubscriber.email,
509 token: pageSubscriber.token,
510 })
511 .from(pageSubscriber)
512 .where(
513 and(
514 eq(pageSubscriber.pageId, testPageId),
515 isNotNull(pageSubscriber.acceptedAt),
516 isNull(pageSubscriber.unsubscribedAt),
517 ),
518 )
519 .all();
520
521 // Filter for valid tokens (as done in email sending routes)
522 const validRecipients = emailRecipients.filter(
523 (r): r is { email: string; token: string } => r.token !== null,
524 );
525
526 expect(validRecipients.length).toBeGreaterThanOrEqual(1);
527
528 // Each valid recipient should have a UUID token
529 for (const recipient of validRecipients) {
530 expect(recipient.token).toMatch(
531 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
532 );
533 }
534 });
535});
536
537describe("Re-subscription after unsubscribe flow", () => {
538 let resubscribeToken: string;
539
540 beforeAll(async () => {
541 // Clean up
542 await db
543 .delete(pageSubscriber)
544 .where(eq(pageSubscriber.email, "resubscribe-test@example.com"));
545
546 // Create an initially subscribed and verified user
547 const subscriber = await db
548 .insert(pageSubscriber)
549 .values({
550 pageId: testPageId,
551 email: "resubscribe-test@example.com",
552 token: crypto.randomUUID(),
553 acceptedAt: new Date(),
554 unsubscribedAt: null,
555 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
556 })
557 .returning()
558 .get();
559
560 if (!subscriber.token) {
561 throw new Error("Subscriber token is undefined");
562 }
563
564 resubscribeToken = subscriber.token;
565 });
566
567 afterAll(async () => {
568 await db
569 .delete(pageSubscriber)
570 .where(eq(pageSubscriber.email, "resubscribe-test@example.com"));
571 });
572
573 test("User can complete full subscribe -> unsubscribe -> resubscribe cycle", async () => {
574 // Step 1: Verify initial subscription state
575 let subscriber = await db.query.pageSubscriber.findFirst({
576 where: eq(pageSubscriber.email, "resubscribe-test@example.com"),
577 });
578
579 expect(subscriber?.acceptedAt).not.toBeNull();
580 expect(subscriber?.unsubscribedAt).toBeNull();
581
582 // Step 2: User unsubscribes
583 await db
584 .update(pageSubscriber)
585 .set({ unsubscribedAt: new Date() })
586 .where(eq(pageSubscriber.id, subscriber?.id));
587
588 subscriber = await db.query.pageSubscriber.findFirst({
589 where: eq(pageSubscriber.id, subscriber?.id),
590 });
591
592 expect(subscriber?.unsubscribedAt).not.toBeNull();
593
594 // Step 3: User is excluded from emails
595 const subscribersAfterUnsub = await db
596 .select()
597 .from(pageSubscriber)
598 .where(
599 and(
600 eq(pageSubscriber.pageId, testPageId),
601 eq(pageSubscriber.email, "resubscribe-test@example.com"),
602 isNotNull(pageSubscriber.acceptedAt),
603 isNull(pageSubscriber.unsubscribedAt),
604 ),
605 )
606 .all();
607
608 expect(subscribersAfterUnsub.length).toBe(0);
609
610 // Step 4: User re-subscribes (simulating the re-subscription flow)
611 const newToken = crypto.randomUUID();
612 await db
613 .update(pageSubscriber)
614 .set({
615 unsubscribedAt: null,
616 acceptedAt: null, // Requires re-verification
617 token: newToken,
618 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
619 })
620 .where(eq(pageSubscriber.id, subscriber?.id));
621
622 // Step 5: User is still excluded (not yet verified)
623 const subscribersPendingVerify = await db
624 .select()
625 .from(pageSubscriber)
626 .where(
627 and(
628 eq(pageSubscriber.pageId, testPageId),
629 eq(pageSubscriber.email, "resubscribe-test@example.com"),
630 isNotNull(pageSubscriber.acceptedAt),
631 isNull(pageSubscriber.unsubscribedAt),
632 ),
633 )
634 .all();
635
636 expect(subscribersPendingVerify.length).toBe(0);
637
638 // Step 6: User verifies their email again
639 await db
640 .update(pageSubscriber)
641 .set({ acceptedAt: new Date() })
642 .where(eq(pageSubscriber.token, newToken));
643
644 // Step 7: User is now included in email list again
645 const subscribersAfterReverify = await db
646 .select()
647 .from(pageSubscriber)
648 .where(
649 and(
650 eq(pageSubscriber.pageId, testPageId),
651 eq(pageSubscriber.email, "resubscribe-test@example.com"),
652 isNotNull(pageSubscriber.acceptedAt),
653 isNull(pageSubscriber.unsubscribedAt),
654 ),
655 )
656 .all();
657
658 expect(subscribersAfterReverify.length).toBe(1);
659 expect(subscribersAfterReverify[0].token).toBe(newToken);
660 expect(subscribersAfterReverify[0].token).not.toBe(resubscribeToken);
661 });
662});
663
664describe("Invalid token handling", () => {
665 test("Non-existent token returns no subscriber", async () => {
666 const fakeToken = crypto.randomUUID();
667
668 const subscriber = await db.query.pageSubscriber.findFirst({
669 where: eq(pageSubscriber.token, fakeToken),
670 });
671
672 expect(subscriber).toBeUndefined();
673 });
674
675 test("Invalid UUID format is handled gracefully", async () => {
676 const invalidToken = "not-a-valid-uuid";
677
678 // The database query will still work, just return no results
679 const subscriber = await db.query.pageSubscriber.findFirst({
680 where: eq(pageSubscriber.token, invalidToken),
681 });
682
683 expect(subscriber).toBeUndefined();
684 });
685
686 test("Already unsubscribed token returns subscriber with unsubscribedAt set", async () => {
687 // Create an unsubscribed subscriber
688 await db
689 .delete(pageSubscriber)
690 .where(eq(pageSubscriber.email, "already-unsub@example.com"));
691
692 const subscriber = await db
693 .insert(pageSubscriber)
694 .values({
695 pageId: testPageId,
696 email: "already-unsub@example.com",
697 token: crypto.randomUUID(),
698 acceptedAt: new Date(),
699 unsubscribedAt: new Date(),
700 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
701 })
702 .returning()
703 .get();
704
705 if (!subscriber.token) {
706 throw new Error("Subscriber token is undefined");
707 }
708
709 // Query the subscriber
710 const found = await db.query.pageSubscriber.findFirst({
711 where: eq(pageSubscriber.token, subscriber.token),
712 });
713
714 expect(found).toBeDefined();
715 expect(found?.unsubscribedAt).not.toBeNull();
716
717 // Clean up
718 await db
719 .delete(pageSubscriber)
720 .where(eq(pageSubscriber.email, "already-unsub@example.com"));
721 });
722});