Skip to content

Commit 2f3caa0

Browse files
authored
Merge pull request #556 from rozemary2026-a11y/feat/issue-534-webhook-contract-tests
test(contracts): cover webhook, event-replay, analytics, and notification endpoints
2 parents 070a431 + 45f56ee commit 2f3caa0

13 files changed

Lines changed: 548 additions & 62 deletions

File tree

api/src/contract-provider.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Test, TestingModule } from "@nestjs/testing"
1818
import {
1919
authContracts,
2020
loginBody,
21+
notificationsContracts,
2122
PLACEHOLDER,
2223
registerBody,
2324
resolvePath,
@@ -38,6 +39,9 @@ import { JwtExtractorService } from "./common/guards/jwt-extractor.service"
3839
import { StreamOwnershipGuard } from "./common/guards/stream-ownership.guard"
3940
import { StreamOwnershipService } from "./common/guards/stream-ownership.service"
4041
import createJwtConfig, { createRefreshJwtConfig } from "./config/jwt.config"
42+
import { NotificationsController } from "./notifications/notifications.controller"
43+
import { NotificationsService } from "./notifications/notifications.service"
44+
import { NotificationsRepository } from "./notifications/repository/notifications.repository"
4145
import { StreamsRepository } from "./streams/repository/streams.repository"
4246
import { StreamApiKeyGuard } from "./streams/stream-api-key.guard"
4347
import { StreamsController } from "./streams/streams.controller"
@@ -132,6 +136,7 @@ describe("Contract provider verification (api)", () => {
132136
StreamTagsController,
133137
AuthController,
134138
WebhooksController,
139+
NotificationsController,
135140
],
136141
providers: [
137142
StreamsService,
@@ -147,6 +152,8 @@ describe("Contract provider verification (api)", () => {
147152
provide: WebhookDeliveriesRepository,
148153
useValue: deliveriesRepository,
149154
},
155+
NotificationsService,
156+
NotificationsRepository,
150157
AuthGuard,
151158
JwtExtractorService,
152159
StreamOwnershipGuard,
@@ -253,6 +260,20 @@ describe("Contract provider verification (api)", () => {
253260
delivery.nextAttemptAt = null
254261
delivery.lastError = "connection refused"
255262
existingDeliveryId = String(delivery.id)
263+
264+
// Record one processed event so `list-stream-events` validates the
265+
// non-empty shape — most importantly the stringified id/streamId.
266+
await streamsRepository.recordEvent(stream.id, {
267+
eventType: "stream:started",
268+
payload: { streamId: stream.id },
269+
occurredAt: "2026-08-01T00:00:00.000Z",
270+
})
271+
272+
// Seed one unread notification so `list-notifications` validates the
273+
// non-empty shape (numeric ids, ISO timestamps).
274+
await moduleFixture
275+
.get(NotificationsRepository)
276+
.create(userId, "stream:started", { streamId: stream.id })
256277
})
257278

258279
afterAll(async () => {
@@ -362,6 +383,22 @@ describe("Contract provider verification (api)", () => {
362383
})
363384
})
364385

386+
describe.each(notificationsContracts)("$name", (contract) => {
387+
it(contract.description, async () => {
388+
const res = await execute(contract)
389+
390+
expect(res.status).toBe(contract.response.status)
391+
const result = contract.response.schema.safeParse(res.body)
392+
if (!result.success) {
393+
throw new Error(
394+
`${contract.name}: response did not satisfy the contract schema\n` +
395+
`${JSON.stringify(result.error.format(), null, 2)}\n` +
396+
`body: ${JSON.stringify(res.body, null, 2)}`,
397+
)
398+
}
399+
})
400+
})
401+
365402
it("login contract uses credentials the register contract actually created", () => {
366403
// Sanity check that the two contract fixtures stay in sync with each
367404
// other — if this ever fails, `auth.contract.ts` was edited so the

api/src/streams/streams.service.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
} from "@nestjs/common"
66
import * as fc from "fast-check"
77

8-
98
import { Stream } from "./stream.entity"
109
import { StreamsGateway } from "../gateways/streams.gateway"
1110
import { Tag } from "../tags/tag.entity"

tests/contracts/src/auth.contract.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { CreateUserDto } from "@xstreamroll/types"
2-
import type { Contract } from "./contract"
31
import { authResponseSchema } from "./schemas"
42

3+
import type { Contract } from "./contract"
4+
import type { CreateUserDto } from "@xstreamroll/types"
5+
56
export const registerBody: CreateUserDto = {
67
username: "contractuser",
78
email: "contract-user@example.com",

tests/contracts/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ export * from "./schemas"
1818
export * from "./streams.contract"
1919
export * from "./auth.contract"
2020
export * from "./webhooks.contract"
21+
export * from "./notifications.contract"
2122

2223
import { authContracts } from "./auth.contract"
24+
import { notificationsContracts } from "./notifications.contract"
2325
import { streamsContracts } from "./streams.contract"
2426
import { webhooksContracts } from "./webhooks.contract"
2527

@@ -30,4 +32,5 @@ export const allContracts: Contract[] = [
3032
...streamsContracts,
3133
...authContracts,
3234
...webhooksContracts,
35+
...notificationsContracts,
3336
]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type Contract } from "./contract"
2+
import { notificationsPageSchema } from "./schemas"
3+
4+
/**
5+
* Contract coverage for `api/src/notifications/notifications.controller.ts`
6+
* (issue #534). The provider suite seeds one unread notification for the
7+
* fixture user in beforeAll, so this contract validates the non-empty
8+
* shape — including the numeric ids and ISO-string timestamps.
9+
*/
10+
export const notificationsContracts: Contract[] = [
11+
{
12+
name: "list-notifications",
13+
description: "GET /notifications returns the caller's unread notifications in the paginated envelope",
14+
consumer: "xstreamroll-sdk",
15+
provider: "api",
16+
request: {
17+
method: "GET",
18+
path: "/notifications",
19+
query: { page: 1, limit: 20 },
20+
authenticated: true,
21+
},
22+
response: {
23+
status: 200,
24+
schema: notificationsPageSchema,
25+
},
26+
},
27+
]

tests/contracts/src/schemas.ts

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
ApiErrorResponse,
55
PaginatedResponse,
66
Stream,
7+
StreamEventRecord,
78
Tag,
89
User,
910
} from "@xstreamroll/types"
@@ -85,6 +86,95 @@ export const authResponseSchema = z.object({
8586
refreshToken: z.string(),
8687
})
8788

89+
/** The closed set of stream lifecycle/data event types. */
90+
export const streamEventTypeSchema = z.enum([
91+
"stream:started",
92+
"stream:stopped",
93+
"stream:error",
94+
"viewer:joined",
95+
"viewer:left",
96+
"data",
97+
])
98+
99+
/**
100+
* A single persisted stream event, as returned by `GET /streams/:id/events`
101+
* (issue #396). The `id`/`streamId` are strings on the wire — the same
102+
* stringification that already bit `Stream` and `User` — and the schema
103+
* pins that choice so a regression fails the provider suite.
104+
*/
105+
export const streamEventRecordSchema = typed<StreamEventRecord>()(
106+
z.object({
107+
id: z.string(),
108+
streamId: z.string(),
109+
eventType: streamEventTypeSchema,
110+
payload: z.record(z.string(), z.unknown()),
111+
occurredAt: z.string(),
112+
}),
113+
)
114+
115+
export const paginatedStreamEventsSchema = z.object({
116+
data: z.array(streamEventRecordSchema),
117+
page: z.number(),
118+
limit: z.number(),
119+
total: z.number(),
120+
hasMore: z.boolean(),
121+
})
122+
123+
/**
124+
* Aggregate analytics returned by `GET /streams/:id/analytics`.
125+
* Mirrors `StreamAnalyticsDto` in the API — no shared
126+
* `@xstreamroll/types` interface exists yet, so the schema is written
127+
* by hand and pinned by the consumer test's type assertion.
128+
*/
129+
export const streamAnalyticsSchema = z.object({
130+
streamId: z.number(),
131+
totalEventsProcessed: z.object({
132+
last24h: z.number(),
133+
last7d: z.number(),
134+
last30d: z.number(),
135+
}),
136+
errorRate: z.object({
137+
window: z.literal("30d"),
138+
totalEvents: z.number(),
139+
errorEvents: z.number(),
140+
percentage: z.number(),
141+
}),
142+
processingLatency: z.object({
143+
window: z.literal("30d"),
144+
averageMs: z.number().nullable(),
145+
p99Ms: z.number().nullable(),
146+
}),
147+
eventsPerMinute: z.array(
148+
z.object({
149+
minute: z.string(),
150+
count: z.number(),
151+
}),
152+
),
153+
generatedAt: z.string(),
154+
})
155+
156+
/**
157+
* A single unread notification, as returned by `GET /notifications`.
158+
* Numeric ids and ISO-string timestamps on the wire.
159+
*/
160+
export const notificationSchema = z.object({
161+
id: z.number(),
162+
userId: z.number(),
163+
type: z.string(),
164+
payload: z.record(z.string(), z.unknown()),
165+
readAt: z.string().nullable(),
166+
createdAt: z.string(),
167+
expiresAt: z.string(),
168+
})
169+
170+
export const notificationsPageSchema = z.object({
171+
data: z.array(notificationSchema),
172+
page: z.number(),
173+
limit: z.number(),
174+
total: z.number(),
175+
unreadCount: z.number(),
176+
})
177+
88178
/**
89179
* Shape returned by `POST /streams/events` (issue #514) and, per row,
90180
* by `GET /streams/pending` — the `stream_data` wire shape the worker
@@ -98,14 +188,18 @@ export const pendingStreamEventSchema = z.object({
98188

99189
/**
100190
* A webhook subscription as returned by `POST /webhooks` — the only
101-
* response that includes the signing `secret`.
191+
* response that includes the signing `secret`. Field types mirror the
192+
* SDK's `WebhookSubscription` exactly (ids accept string or number on
193+
* the wire; `events` is pinned to the closed {@link streamEventTypeSchema}
194+
* union the SDK's `StreamEventType` declares) so a server-side type
195+
* change fails CI (issue #534).
102196
*/
103197
export const webhookSubscriptionSchema = z.object({
104198
id: z.union([z.string(), z.number()]),
105199
userId: z.union([z.string(), z.number()]),
106200
streamId: z.union([z.string(), z.number()]),
107201
url: z.string(),
108-
events: z.array(z.string()),
202+
events: z.array(streamEventTypeSchema),
109203
secret: z.string(),
110204
active: z.boolean(),
111205
createdAt: z.string(),
@@ -127,11 +221,16 @@ export const paginatedWebhookSubscriptionsSchema = z.object({
127221
limit: z.number(),
128222
})
129223

130-
/** A single webhook delivery, as returned by the deliveries endpoints. */
224+
/**
225+
* A single webhook delivery, as returned by the deliveries endpoints.
226+
* Field types mirror the SDK's `WebhookDelivery` exactly (issue #534),
227+
* including the `id`/`webhookSubscriptionId` string-vs-number choice and
228+
* the closed `event` union.
229+
*/
131230
export const webhookDeliverySchema = z.object({
132231
id: z.union([z.string(), z.number()]),
133232
webhookSubscriptionId: z.union([z.string(), z.number()]),
134-
event: z.string(),
233+
event: streamEventTypeSchema,
135234
payload: z.record(z.string(), z.unknown()),
136235
status: z.enum(["pending", "success", "failed"]),
137236
attemptCount: z.number(),
@@ -143,6 +242,13 @@ export const webhookDeliverySchema = z.object({
143242
createdAt: z.string(),
144243
})
145244

245+
export const paginatedWebhookDeliveriesSchema = z.object({
246+
data: z.array(webhookDeliverySchema),
247+
total: z.number(),
248+
page: z.number(),
249+
limit: z.number(),
250+
})
251+
146252
export const apiErrorSchema = typed<ApiErrorResponse>()(
147253
z.object({
148254
statusCode: z.number(),

tests/contracts/src/streams.contract.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { PLACEHOLDER, type Contract } from "./contract"
22
import {
33
apiErrorSchema,
4+
paginatedStreamEventsSchema,
45
paginatedStreamsSchema,
56
pendingStreamEventSchema,
7+
streamAnalyticsSchema,
68
streamSchema,
79
} from "./schemas"
810

@@ -109,42 +111,39 @@ export const streamsContracts: Contract[] = [
109111
},
110112
},
111113
{
112-
// Runs after the seed stream exists (created in the provider suite's
113-
// beforeAll). `q=seed` matches the seed stream's name
114-
// case-insensitively; the response is the standard paginated
115-
// envelope over the filtered set (issue #532).
116-
name: "list-streams-search",
117-
description: "GET /streams?q=… returns only streams matching the search, in the paginated envelope",
114+
// The provider suite records one event on the seeded stream in
115+
// beforeAll, so this contract validates the non-empty shape — most
116+
// importantly the stringified `id`/`streamId` fields (issue #534).
117+
name: "list-stream-events",
118+
description: "GET /streams/:id/events replays the stream's event log in the paginated envelope",
118119
consumer: "xstreamroll-sdk",
119120
provider: "api",
120121
request: {
121122
method: "GET",
122-
path: "/streams",
123-
query: { page: 1, limit: 20, q: "seed" },
123+
path: "/streams/:id/events",
124+
pathParams: { id: PLACEHOLDER.EXISTING_STREAM_ID },
125+
query: { page: 1, limit: 50 },
124126
authenticated: true,
125127
},
126128
response: {
127129
status: 200,
128-
schema: paginatedStreamsSchema,
130+
schema: paginatedStreamEventsSchema,
129131
},
130132
},
131133
{
132-
// `tag=live-streaming` matches the seed stream, which the provider
133-
// suite tags in beforeAll. Unknown tags return an empty page, so the
134-
// shape contract holds either way (issue #532).
135-
name: "list-streams-by-tag",
136-
description: "GET /streams?tag=… returns only streams carrying the tag, in the paginated envelope",
134+
name: "get-stream-analytics",
135+
description: "GET /streams/:id/analytics returns the aggregate analytics shape",
137136
consumer: "xstreamroll-sdk",
138137
provider: "api",
139138
request: {
140139
method: "GET",
141-
path: "/streams",
142-
query: { page: 1, limit: 20, tag: "live-streaming" },
140+
path: "/streams/:id/analytics",
141+
pathParams: { id: PLACEHOLDER.EXISTING_STREAM_ID },
143142
authenticated: true,
144143
},
145144
response: {
146145
status: 200,
147-
schema: paginatedStreamsSchema,
146+
schema: streamAnalyticsSchema,
148147
},
149148
},
150149
{

0 commit comments

Comments
 (0)