diff --git a/README.md b/README.md index 55f947ff..d917a858 100644 --- a/README.md +++ b/README.md @@ -2815,3 +2815,21 @@ Campaign analytics shows `Skipped by frequency cap`; recipient output includes This implementation keeps the published SDK pin unchanged. Release waits for a matching SDK prerelease and accepted API contract; source support does not imply package publication or beta availability. + +## Channel update metadata + +Use the existing event stream to read `newsletter.update` observations as JSON lines: + +```sh +polymorfa event stream --owner project --project "$PROJECT_ID" --type newsletter.update --json +``` + +`live_update` messages contain reported counts and positions, with optional message +references. Missing counts are not zero. Preserve every typed or untyped view +value and unknown milestone type. Position-only observations do not provide +message action authority. The payload contains no message body. + +`channel message list --json` and `channel message updates --json` preserve a +reported `forwards` value, including zero. Event lists remain metadata-only; +`event view --include-payload --json` returns exact retained bytes only when the +API authorizes them. These commands do not request additional retention. diff --git a/contracts/newsletter-observations.json b/contracts/newsletter-observations.json new file mode 100644 index 00000000..c3a83355 --- /dev/null +++ b/contracts/newsletter-observations.json @@ -0,0 +1,29 @@ +{ + "repository": "polymorfa/polymorfa", + "sourceCommit": "d60f08042a5e3f36e53a7b378be7b7e710e80e9f", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "9253f7fcf7b6e802411cc2f9b3b28bbf7c1e45e90c2ebee7fc461f5627ca9a32", + "polymorfaVersion": "2026-09-22", + "sdkSourceCommit": "d7977377f97b9fa12b8837ba02bc30c6fb512733", + "localCandidateSdkArtifactSha256": "fc2b0ba01891c38956055e1719c726e73630d8fa8f778f6a6f1a69018d54d651", + "published": false, + "productionDependencyPinUnchanged": "0.1.0-dev.20261004024706", + "scope": "Existing channel JSON and event view/stream retain optional newsletter metadata; no new commands. Event list stays metadata-only.", + "releaseGate": "Matching accepted and published SDK required before a real CLI dependency-pin update; local artifact testing is not registry publication.", + "qualification": { + "cliSource": "131b02f1813e383140b26ef31bd7e8726367ebb0", + "cliArtifactSha256": "027e0af085db135ee295aa42d92d0f7e9d87cee4cad00cdf7f28c7d626c366fa", + "sdkArtifactSha256": "fc2b0ba01891c38956055e1719c726e73630d8fa8f778f6a6f1a69018d54d651", + "checks": [ + "channel list/update forwards zero", + "metadata-only event list", + "exact retained event bytes", + "three native SSE events to CLI NDJSON" + ], + "dependencyRebind": "Final SDK Rust README fix changes no TypeScript package bytes; the packed SHA-256 matches the artifact used by this physical CLI test.", + "fullCliTestsPassed": 1091, + "productionAuditVulnerabilities": 0, + "zod": "4.6.7", + "liveAcceptance": false + } +} diff --git a/test/commands/newsletter-observations.test.ts b/test/commands/newsletter-observations.test.ts new file mode 100644 index 00000000..6c1d854f --- /dev/null +++ b/test/commands/newsletter-observations.test.ts @@ -0,0 +1,168 @@ +import {readFileSync} from 'node:fs' +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' +import { + startHttpServer, + startHttpSequenceServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' +interface FixtureEnvelope { + readonly id: string + readonly session: string + readonly timestamp: string + readonly event: string + readonly payload: { + readonly id: string + readonly action: string + readonly muted?: boolean + readonly messages?: readonly { + readonly position: number + readonly forwards?: number + readonly reactionCounts?: Readonly> + }[] + readonly milestone?: {readonly type: string; readonly value: number} + } +} +const fixture = JSON.parse( + readFileSync( + new URL( + '../fixtures/newsletter-observations/delivery.json', + import.meta.url, + ), + 'utf8', + ), +) as {readonly events: readonly FixtureEnvelope[]} +const channel = JSON.parse( + readFileSync( + new URL( + '../fixtures/newsletter-observations/channel-message.json', + import.meta.url, + ), + 'utf8', + ), +) as Record +const PROJECT = '11111111-1111-4111-8111-111111111111' +const metadata = (index: number, includePayload: boolean) => ({ + id: fixture.events[index]!.id, + organizationId: 'org_1', + projectId: PROJECT, + type: 'newsletter.update', + source: 'runtime', + environment: 'development', + createdAt: fixture.events[index]!.timestamp, + payloadAvailability: includePayload ? 'available' : 'not_retained', + payload: includePayload + ? { + encoding: 'base64', + contentType: 'application/json', + data: Buffer.from(JSON.stringify(fixture.events[index])).toString( + 'base64', + ), + } + : null, + replayableUntil: null, + metadataExpiresAt: '2026-11-27T00:00:00.000Z', +}) +describe('newsletter observation consumers', () => { + let server: TestHttpServer | undefined + afterEach(async () => { + await server?.close() + server = undefined + }) + const environment = (run: () => Promise) => + withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server!.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + run, + ) + test.each(['list', 'updates'])( + 'retains reported forward zero in channel message %s JSON', + async (command) => { + server = await startHttpServer({body: {success: true, data: [channel]}}) + const result = await environment(() => + runCommand( + `channel message ${command} ${fixture.events[0]!.payload.id} --session number_1 --json`, + ), + ) + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual([ + channel, + ]) + }, + ) + test('keeps event list metadata separate from explicitly requested retained payload bytes', async () => { + const listed = metadata(0, false), + detail = metadata(0, true) + server = await startHttpSequenceServer([ + {body: {data: [listed], page: {hasMore: false, nextCursor: null}}}, + {body: {data: detail}}, + ]) + const list = await environment(() => + runCommand( + `event list --owner project --project ${PROJECT} --type newsletter.update --json`, + ), + ) + expect(list.error).toBeUndefined() + expect((JSON.parse(list.stdout) as {data: unknown}).data).toEqual([listed]) + const view = await environment(() => + runCommand( + `event view ${detail.id} --owner project --project ${PROJECT} --include-payload --json`, + ), + ) + expect(view.error).toBeUndefined() + const bytes = (JSON.parse(view.stdout) as {data: {payload: {data: string}}}) + .data.payload.data + expect(JSON.parse(Buffer.from(bytes, 'base64').toString())).toEqual( + fixture.events[0], + ) + expect(server.requests[1]?.url).toContain('includePayload=true') + }) + test('preserves native live, milestone and legacy payloads through real SDK SSE into NDJSON', async () => { + const ready = { + v: 1, + type: 'ready', + streamId: 's', + sequence: 1, + heartbeatIntervalMs: 15000, + } + const frames = fixture.events + .map( + (_: unknown, index: number) => + `id: c${index}\nevent: event\ndata: ${JSON.stringify({v: 1, type: 'event', streamId: 's', sequence: index + 2, sentAt: fixture.events[index]!.timestamp, cursor: `c${index}`, event: metadata(index, true)})}\n\n`, + ) + .join('') + server = await startHttpSequenceServer([ + { + headers: {'content-type': 'text/event-stream'}, + rawBody: `event: ready\ndata: ${JSON.stringify(ready)}\n\n${frames}`, + }, + {status: 204}, + ]) + const result = await environment(() => + runCommand( + `event stream --owner project --project ${PROJECT} --type newsletter.update --max-events 3 --json`, + ), + ) + expect(result.error).toBeUndefined() + const lines = result.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line) as {webhook: FixtureEnvelope}) + expect(lines).toHaveLength(3) + expect(lines.map((line) => line.webhook)).toEqual(fixture.events) + expect(lines[0]?.webhook.payload.messages?.[0]?.forwards).toBe(0) + expect(lines[0]?.webhook.payload.messages?.[0]?.reactionCounts).toEqual({}) + expect(lines[0]?.webhook.payload.messages?.[1]).toEqual({position: 124}) + expect(lines[1]?.webhook.payload.milestone).toMatchObject({ + type: 'FUTURE_MILESTONE', + value: 0, + }) + expect(lines[2]?.webhook.payload.muted).toBe(false) + }) +}) diff --git a/test/fixtures/newsletter-observations/channel-message.json b/test/fixtures/newsletter-observations/channel-message.json new file mode 100644 index 00000000..1595b387 --- /dev/null +++ b/test/fixtures/newsletter-observations/channel-message.json @@ -0,0 +1,16 @@ +{ + "position": 123, + "id": "9007199254740994", + "whatsapp_ids": { + "linked_devices": "native-observed" + }, + "whatsapp_id": "native-observed", + "conversation": { + "id": "9007199254740993" + }, + "type": "text", + "timestamp": "2023-11-14T22:13:21Z", + "views": 11, + "forwards": 0, + "reactionCounts": {} +} diff --git a/test/fixtures/newsletter-observations/delivery.json b/test/fixtures/newsletter-observations/delivery.json new file mode 100644 index 00000000..22829e89 --- /dev/null +++ b/test/fixtures/newsletter-observations/delivery.json @@ -0,0 +1,70 @@ +{ + "secret": "synthetic-newsletter-secret", + "batchId": "0198a2cb-0000-7000-8000-000000000081", + "events": [ + { + "id": "newsletter-event-0", + "session": "channel-number", + "timestamp": "2026-10-27T00:00:00.000Z", + "event": "newsletter.update", + "payload": { + "id": "9007199254740993", + "action": "live_update", + "sourceTimestamp": "2023-11-14T22:13:20Z", + "messages": [ + { + "position": 123, + "message": { + "id": "9007199254740994", + "whatsapp_ids": { + "linked_devices": "native-observed" + } + }, + "timestamp": "2023-11-14T22:13:21Z", + "viewCounts": [ + { + "count": 0 + }, + { + "type": "views", + "count": 11 + } + ], + "forwards": 0, + "reactionCounts": {} + }, + { + "position": 124 + } + ] + } + }, + { + "id": "newsletter-event-1", + "session": "channel-number", + "timestamp": "2026-10-27T00:00:00.000Z", + "event": "newsletter.update", + "payload": { + "id": "9007199254740993", + "action": "milestone", + "milestone": { + "position": 123, + "type": "FUTURE_MILESTONE", + "value": 0, + "reactionCode": "👍" + } + } + }, + { + "id": "legacy-mute", + "session": "channel-number", + "timestamp": "2026-10-27T00:00:00.000Z", + "event": "newsletter.update", + "payload": { + "id": "9007199254740993", + "action": "mute", + "muted": false + } + } + ] +}