Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 722 lines 22 kB view raw
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});