Openstatus www.openstatus.dev
at main 926 lines 29 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 unsubscribedToken: string; 336 let pendingToken: string; 337 338 beforeAll(async () => { 339 // Clean up and create multiple subscribers with different states 340 await db 341 .delete(pageSubscriber) 342 .where(eq(pageSubscriber.email, "active-user@example.com")); 343 await db 344 .delete(pageSubscriber) 345 .where(eq(pageSubscriber.email, "unsubscribed-user@example.com")); 346 await db 347 .delete(pageSubscriber) 348 .where(eq(pageSubscriber.email, "pending-user@example.com")); 349 350 // Active subscriber 351 const active = await db 352 .insert(pageSubscriber) 353 .values({ 354 pageId: testPageId, 355 email: "active-user@example.com", 356 token: crypto.randomUUID(), 357 acceptedAt: new Date(), 358 unsubscribedAt: null, 359 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 360 }) 361 .returning() 362 .get(); 363 364 if (!active.token) { 365 throw new Error("Active subscriber token is undefined"); 366 } 367 368 // Unsubscribed subscriber 369 const unsubscribed = await db 370 .insert(pageSubscriber) 371 .values({ 372 pageId: testPageId, 373 email: "unsubscribed-user@example.com", 374 token: crypto.randomUUID(), 375 acceptedAt: new Date(), 376 unsubscribedAt: new Date(), 377 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 378 }) 379 .returning() 380 .get(); 381 382 if (!unsubscribed.token) { 383 throw new Error("Unsubscribed subscriber token is undefined"); 384 } 385 386 unsubscribedToken = unsubscribed.token; 387 388 // Pending (unverified) subscriber 389 const pending = await db 390 .insert(pageSubscriber) 391 .values({ 392 pageId: testPageId, 393 email: "pending-user@example.com", 394 token: crypto.randomUUID(), 395 acceptedAt: null, 396 unsubscribedAt: null, 397 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 398 }) 399 .returning() 400 .get(); 401 402 if (!pending.token) { 403 throw new Error("Pending subscriber token is undefined"); 404 } 405 406 pendingToken = pending.token; 407 }); 408 409 afterAll(async () => { 410 await db 411 .delete(pageSubscriber) 412 .where(eq(pageSubscriber.email, "active-user@example.com")); 413 await db 414 .delete(pageSubscriber) 415 .where(eq(pageSubscriber.email, "unsubscribed-user@example.com")); 416 await db 417 .delete(pageSubscriber) 418 .where(eq(pageSubscriber.email, "pending-user@example.com")); 419 }); 420 421 test("Email query returns only active subscribers with valid tokens", async () => { 422 // This mirrors the exact query pattern used in email-sending routes 423 const emailRecipients = await db 424 .select({ 425 email: pageSubscriber.email, 426 token: pageSubscriber.token, 427 }) 428 .from(pageSubscriber) 429 .where( 430 and( 431 eq(pageSubscriber.pageId, testPageId), 432 isNotNull(pageSubscriber.acceptedAt), 433 isNull(pageSubscriber.unsubscribedAt), 434 ), 435 ) 436 .all(); 437 438 // Should only include active subscriber 439 expect(emailRecipients.length).toBeGreaterThanOrEqual(1); 440 441 const emails = emailRecipients.map((r) => r.email); 442 expect(emails).toContain("active-user@example.com"); 443 expect(emails).not.toContain("unsubscribed-user@example.com"); 444 expect(emails).not.toContain("pending-user@example.com"); 445 }); 446 447 test("Unsubscribed users are filtered out even with acceptedAt set", async () => { 448 // Verify the unsubscribed user has acceptedAt set 449 const unsubscribedUser = await db.query.pageSubscriber.findFirst({ 450 where: eq(pageSubscriber.token, unsubscribedToken), 451 }); 452 453 expect(unsubscribedUser?.acceptedAt).not.toBeNull(); 454 expect(unsubscribedUser?.unsubscribedAt).not.toBeNull(); 455 456 // Query with proper filters 457 const subscribers = await db 458 .select() 459 .from(pageSubscriber) 460 .where( 461 and( 462 eq(pageSubscriber.pageId, testPageId), 463 isNotNull(pageSubscriber.acceptedAt), 464 isNull(pageSubscriber.unsubscribedAt), 465 ), 466 ) 467 .all(); 468 469 const foundUnsubscribed = subscribers.find( 470 (s) => s.email === "unsubscribed-user@example.com", 471 ); 472 expect(foundUnsubscribed).toBeUndefined(); 473 }); 474 475 test("Pending users are filtered out (not verified)", async () => { 476 // Verify the pending user has no acceptedAt 477 const pendingUser = await db.query.pageSubscriber.findFirst({ 478 where: eq(pageSubscriber.token, pendingToken), 479 }); 480 481 expect(pendingUser?.acceptedAt).toBeNull(); 482 483 // Query with proper filters 484 const subscribers = await db 485 .select() 486 .from(pageSubscriber) 487 .where( 488 and( 489 eq(pageSubscriber.pageId, testPageId), 490 isNotNull(pageSubscriber.acceptedAt), 491 isNull(pageSubscriber.unsubscribedAt), 492 ), 493 ) 494 .all(); 495 496 const foundPending = subscribers.find( 497 (s) => s.email === "pending-user@example.com", 498 ); 499 expect(foundPending).toBeUndefined(); 500 }); 501 502 test("Email recipients list includes token for unsubscribe URL generation", async () => { 503 const emailRecipients = await db 504 .select({ 505 email: pageSubscriber.email, 506 token: pageSubscriber.token, 507 }) 508 .from(pageSubscriber) 509 .where( 510 and( 511 eq(pageSubscriber.pageId, testPageId), 512 isNotNull(pageSubscriber.acceptedAt), 513 isNull(pageSubscriber.unsubscribedAt), 514 ), 515 ) 516 .all(); 517 518 // Filter for valid tokens (as done in email sending routes) 519 const validRecipients = emailRecipients.filter( 520 (r): r is { email: string; token: string } => r.token !== null, 521 ); 522 523 expect(validRecipients.length).toBeGreaterThanOrEqual(1); 524 525 // Each valid recipient should have a UUID token 526 for (const recipient of validRecipients) { 527 expect(recipient.token).toMatch( 528 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, 529 ); 530 } 531 }); 532}); 533 534describe("Re-subscription after unsubscribe flow", () => { 535 let resubscribeToken: string; 536 537 beforeAll(async () => { 538 // Clean up 539 await db 540 .delete(pageSubscriber) 541 .where(eq(pageSubscriber.email, "resubscribe-test@example.com")); 542 543 // Create an initially subscribed and verified user 544 const subscriber = await db 545 .insert(pageSubscriber) 546 .values({ 547 pageId: testPageId, 548 email: "resubscribe-test@example.com", 549 token: crypto.randomUUID(), 550 acceptedAt: new Date(), 551 unsubscribedAt: null, 552 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 553 }) 554 .returning() 555 .get(); 556 557 if (!subscriber.token) { 558 throw new Error("Subscriber token is undefined"); 559 } 560 561 resubscribeToken = subscriber.token; 562 }); 563 564 afterAll(async () => { 565 await db 566 .delete(pageSubscriber) 567 .where(eq(pageSubscriber.email, "resubscribe-test@example.com")); 568 }); 569 570 test("User can complete full subscribe -> unsubscribe -> resubscribe cycle", async () => { 571 // Step 1: Verify initial subscription state 572 let subscriber = await db.query.pageSubscriber.findFirst({ 573 where: eq(pageSubscriber.email, "resubscribe-test@example.com"), 574 }); 575 576 if (!subscriber) { 577 throw new Error("Subscriber ID is undefined"); 578 } 579 580 expect(subscriber?.acceptedAt).not.toBeNull(); 581 expect(subscriber?.unsubscribedAt).toBeNull(); 582 583 // Step 2: User unsubscribes 584 await db 585 .update(pageSubscriber) 586 .set({ unsubscribedAt: new Date() }) 587 .where(eq(pageSubscriber.id, subscriber.id)); 588 589 subscriber = await db.query.pageSubscriber.findFirst({ 590 where: eq(pageSubscriber.id, subscriber.id), 591 }); 592 593 expect(subscriber?.unsubscribedAt).not.toBeNull(); 594 595 // Step 3: User is excluded from emails 596 const subscribersAfterUnsub = await db 597 .select() 598 .from(pageSubscriber) 599 .where( 600 and( 601 eq(pageSubscriber.pageId, testPageId), 602 eq(pageSubscriber.email, "resubscribe-test@example.com"), 603 isNotNull(pageSubscriber.acceptedAt), 604 isNull(pageSubscriber.unsubscribedAt), 605 ), 606 ) 607 .all(); 608 609 expect(subscribersAfterUnsub.length).toBe(0); 610 611 if (!subscriber) { 612 throw new Error("Subscriber is undefined"); 613 } 614 615 // Step 4: User re-subscribes (simulating the re-subscription flow) 616 const newToken = crypto.randomUUID(); 617 await db 618 .update(pageSubscriber) 619 .set({ 620 unsubscribedAt: null, 621 acceptedAt: null, // Requires re-verification 622 token: newToken, 623 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 624 }) 625 .where(eq(pageSubscriber.id, subscriber.id)); 626 627 // Step 5: User is still excluded (not yet verified) 628 const subscribersPendingVerify = await db 629 .select() 630 .from(pageSubscriber) 631 .where( 632 and( 633 eq(pageSubscriber.pageId, testPageId), 634 eq(pageSubscriber.email, "resubscribe-test@example.com"), 635 isNotNull(pageSubscriber.acceptedAt), 636 isNull(pageSubscriber.unsubscribedAt), 637 ), 638 ) 639 .all(); 640 641 expect(subscribersPendingVerify.length).toBe(0); 642 643 // Step 6: User verifies their email again 644 await db 645 .update(pageSubscriber) 646 .set({ acceptedAt: new Date() }) 647 .where(eq(pageSubscriber.token, newToken)); 648 649 // Step 7: User is now included in email list again 650 const subscribersAfterReverify = await db 651 .select() 652 .from(pageSubscriber) 653 .where( 654 and( 655 eq(pageSubscriber.pageId, testPageId), 656 eq(pageSubscriber.email, "resubscribe-test@example.com"), 657 isNotNull(pageSubscriber.acceptedAt), 658 isNull(pageSubscriber.unsubscribedAt), 659 ), 660 ) 661 .all(); 662 663 expect(subscribersAfterReverify.length).toBe(1); 664 expect(subscribersAfterReverify[0].token).toBe(newToken); 665 expect(subscribersAfterReverify[0].token).not.toBe(resubscribeToken); 666 }); 667}); 668 669describe("Invalid token handling", () => { 670 test("Non-existent token returns no subscriber", async () => { 671 const fakeToken = crypto.randomUUID(); 672 673 const subscriber = await db.query.pageSubscriber.findFirst({ 674 where: eq(pageSubscriber.token, fakeToken), 675 }); 676 677 expect(subscriber).toBeUndefined(); 678 }); 679 680 test("Invalid UUID format is handled gracefully", async () => { 681 const invalidToken = "not-a-valid-uuid"; 682 683 // The database query will still work, just return no results 684 const subscriber = await db.query.pageSubscriber.findFirst({ 685 where: eq(pageSubscriber.token, invalidToken), 686 }); 687 688 expect(subscriber).toBeUndefined(); 689 }); 690 691 test("Already unsubscribed token returns subscriber with unsubscribedAt set", async () => { 692 // Create an unsubscribed subscriber 693 await db 694 .delete(pageSubscriber) 695 .where(eq(pageSubscriber.email, "already-unsub@example.com")); 696 697 const subscriber = await db 698 .insert(pageSubscriber) 699 .values({ 700 pageId: testPageId, 701 email: "already-unsub@example.com", 702 token: crypto.randomUUID(), 703 acceptedAt: new Date(), 704 unsubscribedAt: new Date(), 705 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 706 }) 707 .returning() 708 .get(); 709 710 if (!subscriber.token) { 711 throw new Error("Subscriber token is undefined"); 712 } 713 714 // Query the subscriber 715 const found = await db.query.pageSubscriber.findFirst({ 716 where: eq(pageSubscriber.token, subscriber.token), 717 }); 718 719 expect(found).toBeDefined(); 720 expect(found?.unsubscribedAt).not.toBeNull(); 721 722 // Clean up 723 await db 724 .delete(pageSubscriber) 725 .where(eq(pageSubscriber.email, "already-unsub@example.com")); 726 }); 727}); 728 729describe("statusPage.get endpoint validation", () => { 730 test("Returns all required output fields with correct types", async () => { 731 // Use the edgeRouter to call the statusPage.get endpoint 732 const { edgeRouter } = await import("../edge"); 733 const { createInnerTRPCContext } = await import("../trpc"); 734 735 const ctx = createInnerTRPCContext({ 736 req: undefined, 737 // @ts-expect-error - auth not required for public procedure 738 auth: undefined, 739 }); 740 741 const caller = edgeRouter.createCaller(ctx); 742 const result = await caller.statusPage.get({ slug: testSlug }); 743 744 // Validate that result is not null 745 expect(result).toBeDefined(); 746 expect(result).not.toBeNull(); 747 748 if (!result) { 749 throw new Error("Result should not be null"); 750 } 751 752 // Validate core page fields with specific types 753 expect(typeof result.slug).toBe("string"); 754 expect(typeof result.title).toBe("string"); 755 expect(typeof result.description).toBe("string"); 756 expect(result.createdAt).toBeInstanceOf(Date); 757 expect(result.updatedAt).toBeInstanceOf(Date); 758 759 // Validate slug matches what we requested 760 expect(result.slug).toBe(testSlug); 761 762 // Validate all array fields exist and are arrays 763 expect(Array.isArray(result.monitors)).toBe(true); 764 expect(Array.isArray(result.monitorGroups)).toBe(true); 765 expect(Array.isArray(result.pageComponents)).toBe(true); 766 expect(Array.isArray(result.pageComponentGroups)).toBe(true); 767 expect(Array.isArray(result.trackers)).toBe(true); 768 expect(Array.isArray(result.lastEvents)).toBe(true); 769 expect(Array.isArray(result.openEvents)).toBe(true); 770 expect(Array.isArray(result.statusReports)).toBe(true); 771 expect(Array.isArray(result.incidents)).toBe(true); 772 expect(Array.isArray(result.maintenances)).toBe(true); 773 774 // Validate status field is one of the allowed values 775 expect(["success", "degraded", "error", "info"]).toContain(result.status); 776 777 // Validate workspacePlan field 778 expect(result.workspacePlan).toBeDefined(); 779 expect(typeof result.workspacePlan).toBe("string"); 780 781 // Validate whiteLabel field 782 expect(typeof result.whiteLabel).toBe("boolean"); 783 }); 784 785 test("Returns null for non-existent slug", async () => { 786 const { edgeRouter } = await import("../edge"); 787 const { createInnerTRPCContext } = await import("../trpc"); 788 789 const ctx = createInnerTRPCContext({ 790 req: undefined, 791 // @ts-expect-error - auth not required for public procedure 792 auth: undefined, 793 }); 794 795 const caller = edgeRouter.createCaller(ctx); 796 const result = await caller.statusPage.get({ 797 slug: "non-existent-slug-12345", 798 }); 799 800 expect(result).toBeNull(); 801 }); 802 803 test("Tracker objects have correct discriminated union types", async () => { 804 const { edgeRouter } = await import("../edge"); 805 const { createInnerTRPCContext } = await import("../trpc"); 806 807 const ctx = createInnerTRPCContext({ 808 req: undefined, 809 // @ts-expect-error - auth not required for public procedure 810 auth: undefined, 811 }); 812 813 const caller = edgeRouter.createCaller(ctx); 814 const result = await caller.statusPage.get({ slug: testSlug }); 815 816 if (!result) { 817 // If no result, skip this test as there are no trackers to validate 818 return; 819 } 820 821 // Validate each tracker has the correct structure 822 for (const tracker of result.trackers) { 823 expect(tracker).toHaveProperty("type"); 824 expect(tracker).toHaveProperty("order"); 825 826 if (tracker.type === "component") { 827 expect(tracker).toHaveProperty("component"); 828 expect(tracker.component).toHaveProperty("id"); 829 expect(tracker.component).toHaveProperty("name"); 830 expect(tracker.component).toHaveProperty("status"); 831 expect(tracker.component).toHaveProperty("type"); 832 expect(["monitor", "static"]).toContain(tracker.component.type); 833 expect(["success", "degraded", "error", "info"]).toContain( 834 tracker.component.status, 835 ); 836 837 // Monitor-type components should have monitor relation 838 if (tracker.component.type === "monitor") { 839 expect(tracker.component).toHaveProperty("monitor"); 840 expect(tracker.component.monitor).toBeDefined(); 841 } 842 } else if (tracker.type === "group") { 843 expect(tracker).toHaveProperty("groupId"); 844 expect(tracker).toHaveProperty("groupName"); 845 expect(tracker).toHaveProperty("components"); 846 expect(tracker).toHaveProperty("status"); 847 expect(Array.isArray(tracker.components)).toBe(true); 848 expect(["success", "degraded", "error", "info"]).toContain( 849 tracker.status, 850 ); 851 } 852 } 853 }); 854 855 test("Event objects have required fields", async () => { 856 const { edgeRouter } = await import("../edge"); 857 const { createInnerTRPCContext } = await import("../trpc"); 858 859 const ctx = createInnerTRPCContext({ 860 req: undefined, 861 // @ts-expect-error - auth not required for public procedure 862 auth: undefined, 863 }); 864 865 const caller = edgeRouter.createCaller(ctx); 866 const result = await caller.statusPage.get({ slug: testSlug }); 867 868 if (!result) { 869 return; 870 } 871 872 // Validate lastEvents structure 873 for (const event of result.lastEvents) { 874 expect(event).toMatchObject({ 875 id: expect.any(Number), 876 name: expect.any(String), 877 from: expect.any(Date), 878 status: expect.any(String), 879 type: expect.any(String), 880 }); 881 expect(["maintenance", "incident", "report"]).toContain(event.type); 882 expect(["success", "degraded", "error", "info"]).toContain(event.status); 883 } 884 885 // Validate openEvents structure 886 for (const event of result.openEvents) { 887 expect(event).toMatchObject({ 888 id: expect.any(Number), 889 name: expect.any(String), 890 from: expect.any(Date), 891 status: expect.any(String), 892 type: expect.any(String), 893 }); 894 expect(["maintenance", "incident", "report"]).toContain(event.type); 895 expect(["success", "degraded", "error", "info"]).toContain(event.status); 896 } 897 }); 898 899 test("Monitor objects contain status field", async () => { 900 const { edgeRouter } = await import("../edge"); 901 const { createInnerTRPCContext } = await import("../trpc"); 902 903 const ctx = createInnerTRPCContext({ 904 req: undefined, 905 // @ts-expect-error - auth not required for public procedure 906 auth: undefined, 907 }); 908 909 const caller = edgeRouter.createCaller(ctx); 910 const result = await caller.statusPage.get({ slug: testSlug }); 911 912 if (!result || result.monitors.length === 0) { 913 return; 914 } 915 916 // Validate each monitor has status field 917 for (const monitor of result.monitors) { 918 expect(monitor).toHaveProperty("status"); 919 expect(["success", "degraded", "error", "info"]).toContain( 920 monitor.status, 921 ); 922 expect(monitor).toHaveProperty("id"); 923 expect(monitor).toHaveProperty("name"); 924 } 925 }); 926});