您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

webhook-waiting-querying.md 6.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. # Webhook Waiting and Querying Patterns
  2. ## Principle
  3. `waitFor` and `waitForCount` poll until matching webhooks arrive; `getReceived` queries without waiting. Always drain preceding events before asserting on subsequent ones. Scope templates by entity ID to prevent parallel worker cross-contamination.
  4. ## Pattern Examples
  5. ### Example 1: waitFor — single webhook
  6. Poll until the first webhook matching the template arrives. Returns the typed `ReceivedWebhook<T>`.
  7. ```typescript
  8. const webhook = await webhookRegistry.waitFor(movieCreated(movieId));
  9. expect(webhook.body).toMatchObject({
  10. event: 'movie.created',
  11. timestamp: expect.any(String),
  12. data: {
  13. id: movieId,
  14. name: movie.name,
  15. year: movie.year,
  16. rating: movie.rating,
  17. },
  18. });
  19. ```
  20. ### Example 2: The drain pattern — sequential events
  21. When testing a downstream event (e.g. deletion), always `waitFor` the preceding event first. Without the drain, the create webhook may remain in the journal and interfere with cleanup or subsequent polling.
  22. ```typescript
  23. test('movie deletion triggers a webhook with correct payload', async ({ authToken, addMovie, deleteMovie, webhookRegistry }) => {
  24. const movie = generateMovieWithoutId();
  25. const { body: createResponse } = await addMovie(authToken, movie);
  26. const movieId = createResponse.data.id;
  27. await log.step('Drain the create webhook before testing the delete path');
  28. await webhookRegistry.waitFor(movieCreated(movieId)); // drain — consume the create event
  29. await deleteMovie(authToken, movieId);
  30. await log.step('Wait for the delete webhook');
  31. const webhook = await webhookRegistry.waitFor(movieDeleted(movieId));
  32. expect(webhook.body).toMatchObject({
  33. event: 'movie.deleted',
  34. data: { id: movieId, name: movie.name },
  35. });
  36. });
  37. ```
  38. **Why drain?** If you skip the drain and go directly to `waitFor(movieDeleted)`, the create webhook is already in the journal. The delete webhook may arrive and be cleaned up by another test before your poll reaches it. Draining makes the event order explicit and removes the ambiguity.
  39. ### Example 3: waitForCount — collect N webhooks concurrently
  40. Collect exactly N matching webhooks. Use `matchPredicate` with all IDs to prevent cross-worker contamination when running `fullyParallel: true`:
  41. ```typescript
  42. await log.step('Create two movies concurrently');
  43. const [{ body: res1 }, { body: res2 }] = await Promise.all([
  44. addMovie(authToken, generateMovieWithoutId()),
  45. addMovie(authToken, generateMovieWithoutId()),
  46. ]);
  47. const [id1, id2] = [res1.data.id, res2.data.id];
  48. const batchTemplate = webhookTemplate<{
  49. event: string;
  50. data: { id: number };
  51. }>('movie.created.batch')
  52. .matchField('event', 'movie.created')
  53. .matchPredicate(`data.id is ${id1} or ${id2}`, (p) => p.data.id === id1 || p.data.id === id2)
  54. .withTimeout(15_000)
  55. .withInterval(500)
  56. .build();
  57. const webhooks = await webhookRegistry.waitForCount(batchTemplate, 2);
  58. expect(webhooks).toHaveLength(2);
  59. const receivedIds = webhooks.map((w) => w.body.data.id);
  60. expect(receivedIds).toContain(id1);
  61. expect(receivedIds).toContain(id2);
  62. expect(new Set(receivedIds).size).toBe(2); // guard against the same ID delivered twice
  63. ```
  64. ### Example 4: getReceived — query without waiting
  65. Query the journal without polling. Useful for asserting presence of webhooks after a `waitFor`, or for method/URL filtering.
  66. ```typescript
  67. await webhookRegistry.waitFor(movieCreated(movieId)); // wait first
  68. const all = await webhookRegistry.getReceived();
  69. expect(all.length).toBeGreaterThanOrEqual(1);
  70. // Method filter — all sample-app webhooks are delivered via POST
  71. const postOnly = await webhookRegistry.getReceived({ method: 'POST' });
  72. expect(postOnly.every((w) => w.method === 'POST')).toBe(true);
  73. // URL pattern filter — match the webhooks endpoint path
  74. const byUrl = await webhookRegistry.getReceived({ urlPattern: '/webhooks' });
  75. expect(byUrl.every((w) => w.url.includes('/webhooks'))).toBe(true);
  76. ```
  77. `getReceived` accepts `WebhookQueryFilter`:
  78. ```typescript
  79. type WebhookQueryFilter = {
  80. urlPattern?: string; // glob or regex string
  81. method?: string; // HTTP method filter
  82. since?: Date; // only return webhooks after this timestamp
  83. };
  84. ```
  85. Note: `getReceived` is a direct passthrough to the provider — it does **not** automatically apply the `startedAt` filter. Only `waitFor` and `waitForCount` apply the since-filter internally during polling. If you need to scope a manual `getReceived` call to this test's time window, record your own timestamp before the action under test and pass `{ since: myTimestamp }` explicitly.
  86. ## Parallel Worker Safety
  87. Always scope template factories to the entity's ID:
  88. ```typescript
  89. // ✅ Scoped — only matches webhooks for this specific movie
  90. const movieCreated = (movieId: number) =>
  91. webhookTemplate('movie.created')
  92. .matchField('event', 'movie.created')
  93. .matchField('data.id', movieId) // scoped by ID
  94. .build();
  95. // ❌ Unscoped — will match any movie.created from any parallel worker
  96. const movieCreatedUnscoped = webhookTemplate('movie.created').matchField('event', 'movie.created').build();
  97. ```
  98. ## Method Summary
  99. | Method | Returns | Description |
  100. | --------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- |
  101. | `waitFor(template)` | `Promise<ReceivedWebhook<T>>` | Poll until first match; throws `WebhookTimeoutError` on timeout |
  102. | `waitForCount(template, n)` | `Promise<ReceivedWebhook<T>[]>` | Poll until N matches; throws `WebhookTimeoutError` on timeout |
  103. | `getReceived(filter?)` | `Promise<ReceivedWebhook[]>` | Direct passthrough to provider — no automatic since-filter; pass `{ since }` explicitly if needed |
  104. | `resetJournal()` | `Promise<void>` | Wipe the entire journal and clear matchedIds |
  105. | `cleanup()` | `Promise<void>` | Delete matched webhooks (`matched-only`) or reset journal (`full-reset`) |
  106. ## Anti-Patterns
  107. **DON'T skip the drain for sequential events:**
  108. ```typescript
  109. // Bad: direct jump to delete webhook — create webhook pollutes the journal
  110. await addMovie(authToken, movie);
  111. const webhook = await webhookRegistry.waitFor(movieDeleted(movieId));
  112. ```
  113. **DO drain preceding events:**
  114. ```typescript
  115. // Good: drain create first, then wait for delete
  116. await webhookRegistry.waitFor(movieCreated(movieId)); // drain
  117. await deleteMovie(authToken, movieId);
  118. const webhook = await webhookRegistry.waitFor(movieDeleted(movieId));
  119. ```
  120. ## Related Fragments
  121. - `webhook-template-matchers.md` — How to build templates
  122. - `webhook-timeout-error.md` — What to do when waitFor times out
  123. - `recurse.md` — The polling primitive used internally by the registry