Skip to content

Commit 01a67ef

Browse files
refactor(audience-core): replace Transport interface with HttpSend + structured TransportResult (#2839)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f390789 commit 01a67ef

12 files changed

Lines changed: 296 additions & 104 deletions

File tree

packages/audience/core/src/consent.test.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { createConsentManager } from './consent';
2-
import { httpSend } from './transport';
2+
import type { HttpSend } from './transport';
33

4-
jest.mock('./transport', () => ({
5-
httpSend: jest.fn().mockResolvedValue(true),
6-
}));
7-
8-
const mockHttpSend = httpSend as jest.MockedFunction<typeof httpSend>;
4+
function createMockSend(): jest.MockedFunction<HttpSend> {
5+
return jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({ ok: true });
6+
}
97

108
function createMockQueue() {
119
return {
@@ -29,19 +27,22 @@ beforeEach(() => {
2927
describe('createConsentManager', () => {
3028
it('defaults to none when no initial level provided', () => {
3129
const queue = createMockQueue();
32-
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel');
30+
const send = createMockSend();
31+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel');
3332
expect(manager.level).toBe('none');
3433
});
3534

3635
it('uses the initial level when provided', () => {
3736
const queue = createMockQueue();
38-
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous');
37+
const send = createMockSend();
38+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous');
3939
expect(manager.level).toBe('anonymous');
4040
});
4141

4242
it('upgrades consent without modifying queue', () => {
4343
const queue = createMockQueue();
44-
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none');
44+
const send = createMockSend();
45+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none');
4546

4647
manager.setLevel('anonymous');
4748
expect(manager.level).toBe('anonymous');
@@ -51,7 +52,8 @@ describe('createConsentManager', () => {
5152

5253
it('purges queue on downgrade to none', () => {
5354
const queue = createMockQueue();
54-
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full');
55+
const send = createMockSend();
56+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'full');
5557

5658
manager.setLevel('none');
5759
expect(manager.level).toBe('none');
@@ -64,7 +66,8 @@ describe('createConsentManager', () => {
6466

6567
it('strips userId on downgrade from full to anonymous', () => {
6668
const queue = createMockQueue();
67-
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full');
69+
const send = createMockSend();
70+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'full');
6871

6972
manager.setLevel('anonymous');
7073
expect(manager.level).toBe('anonymous');
@@ -78,13 +81,14 @@ describe('createConsentManager', () => {
7881
expect(result.anonymousId).toBe('a-1');
7982
});
8083

81-
it('fires PUT to consent endpoint on level change', () => {
84+
it('fires PUT to consent endpoint on level change via the injected send', () => {
8285
const queue = createMockQueue();
83-
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none');
86+
const send = createMockSend();
87+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none');
8488

8589
manager.setLevel('anonymous');
8690

87-
expect(mockHttpSend).toHaveBeenCalledWith(
91+
expect(send).toHaveBeenCalledWith(
8892
'https://api.dev.immutable.com/v1/audience/tracking-consent',
8993
'pk_test',
9094
{ anonymousId: 'anon-1', status: 'anonymous', source: 'pixel' },
@@ -94,19 +98,21 @@ describe('createConsentManager', () => {
9498

9599
it('does nothing when setting the same level', () => {
96100
const queue = createMockQueue();
97-
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous');
101+
const send = createMockSend();
102+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous');
98103

99104
manager.setLevel('anonymous');
100105
expect(queue.purge).not.toHaveBeenCalled();
101106
expect(queue.transform).not.toHaveBeenCalled();
102-
expect(mockHttpSend).not.toHaveBeenCalled();
107+
expect(send).not.toHaveBeenCalled();
103108
});
104109

105110
it('respects DNT by defaulting to none', () => {
106111
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
107112

108113
const queue = createMockQueue();
109-
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel');
114+
const send = createMockSend();
115+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel');
110116
expect(manager.level).toBe('none');
111117

112118
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });

packages/audience/core/src/consent.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type {
22
ConsentLevel, ConsentUpdatePayload, Message, Environment,
33
} from './types';
44
import type { MessageQueue } from './queue';
5+
import type { HttpSend } from './transport';
56
import { CONSENT_PATH, getBaseUrl } from './config';
6-
import { httpSend } from './transport';
77

88
export interface ConsentManager {
99
level: ConsentLevel;
@@ -26,10 +26,13 @@ export function detectDoNotTrack(): boolean {
2626
* - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`.
2727
* - On downgrade (e.g. full -> anonymous), strips `userId` from queued messages.
2828
* - On downgrade to `'none'`, purges the queue entirely.
29-
* - Fires PUT to `/v1/audience/tracking-consent` on every state change.
29+
* - Fires PUT to `/v1/audience/tracking-consent` on every state change via
30+
* the injected `send`. Sharing the same `HttpSend` instance with the queue
31+
* keeps the transport layer uniform — no module-level mocking required.
3032
*/
3133
export function createConsentManager(
3234
queue: MessageQueue,
35+
send: HttpSend,
3336
publishableKey: string,
3437
anonymousId: string,
3538
environment: Environment,
@@ -44,7 +47,8 @@ export function createConsentManager(
4447
function notifyBackend(level: ConsentLevel): void {
4548
const url = `${getBaseUrl(environment)}${CONSENT_PATH}`;
4649
const payload: ConsentUpdatePayload = { anonymousId, status: level, source };
47-
httpSend(url, publishableKey, payload, { method: 'PUT', keepalive: true });
50+
// Fire-and-forget. HttpSend never rejects, so a floating promise is safe.
51+
send(url, publishableKey, payload, { method: 'PUT', keepalive: true });
4852
}
4953

5054
const manager: ConsentManager = {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Structured error returned by the transport layer when a send fails.
3+
*
4+
* Extends native {@link Error} so stack traces are captured at the
5+
* construction site and instances flow through standard error-reporting
6+
* tools (browser devtools, Sentry, `@imtbl/metrics`) without custom
7+
* handling.
8+
*
9+
* - `status` is the HTTP status code on a protocol-level error, or `0`
10+
* when the fetch itself rejected (network failure, CORS, DNS).
11+
* - `endpoint` is the full URL that was called.
12+
* - `body` is the parsed response body when content-type was JSON,
13+
* the raw string when not, or undefined when the response had no
14+
* parseable body (e.g. a network failure).
15+
* - `cause` (inherited from {@link Error}, ES2022) is the original error
16+
* object (`TypeError`, `DOMException`, etc.) on a network failure;
17+
* undefined on an HTTP error.
18+
*/
19+
export class TransportError extends Error {
20+
readonly status: number;
21+
22+
readonly endpoint: string;
23+
24+
readonly body?: unknown;
25+
26+
constructor(init: {
27+
status: number;
28+
endpoint: string;
29+
body?: unknown;
30+
cause?: unknown;
31+
}) {
32+
super(
33+
`audience transport failed: ${init.status || 'network error'} ${init.endpoint}`,
34+
init.cause !== undefined ? { cause: init.cause } : undefined,
35+
);
36+
this.name = 'TransportError';
37+
this.status = init.status;
38+
this.endpoint = init.endpoint;
39+
this.body = init.body;
40+
}
41+
}
42+
43+
/**
44+
* Return type of every transport send.
45+
*
46+
* `ok: true` means the backend accepted the payload (HTTP 2xx). On
47+
* `ok: false`, `error` is always populated with a structured reason.
48+
*
49+
* Implementations of `HttpSend` MUST NOT reject — failures travel
50+
* through this result type. Callers (notably `MessageQueue.flushUnload`)
51+
* rely on this contract for fire-and-forget paths.
52+
*/
53+
export interface TransportResult {
54+
ok: boolean;
55+
error?: TransportError;
56+
}

packages/audience/core/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ export {
4141

4242
export { generateId, getTimestamp, isBrowser } from './utils';
4343

44-
export type { Transport, TransportOptions } from './transport';
45-
export { httpTransport, httpSend } from './transport';
44+
export type { HttpSend, TransportOptions } from './transport';
45+
export { httpSend } from './transport';
46+
export type { TransportError, TransportResult } from './errors';
4647
export { MessageQueue } from './queue';
4748
export { collectContext } from './context';
4849
export {

0 commit comments

Comments
 (0)