Skip to content
84 changes: 83 additions & 1 deletion packages/audience/core/src/consent.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createConsentManager } from './consent';
import type { HttpSend } from './transport';
import { TransportError } from './errors';

function createMockSend(): jest.MockedFunction<HttpSend> {
function createMockSend() {
return jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({ ok: true });
}

Expand Down Expand Up @@ -117,4 +118,85 @@ describe('createConsentManager', () => {

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

describe('onError callback', () => {
it('fires onError with mapped CONSENT_SYNC_FAILED on consent PUT failure', async () => {
const queue = createMockQueue();
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({
ok: false,
error: new TransportError({
status: 503,
endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent',
body: { code: 'SERVICE_UNAVAILABLE' },
}),
});
const onError = jest.fn();
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError);

manager.setLevel('anonymous');

// notifyBackend's .then() runs on the microtask queue.
await Promise.resolve();
await Promise.resolve();

expect(onError).toHaveBeenCalledTimes(1);
const err = onError.mock.calls[0][0];
expect(err.code).toBe('CONSENT_SYNC_FAILED');
expect(err.status).toBe(503);
expect(err.message).toBe('Consent sync failed with status 503');
});

it('fires onError with NETWORK_ERROR on network failure', async () => {
const queue = createMockQueue();
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({
ok: false,
error: new TransportError({
status: 0,
endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent',
cause: new TypeError('Failed to fetch'),
}),
});
const onError = jest.fn();
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError);

manager.setLevel('anonymous');
await Promise.resolve();
await Promise.resolve();

expect(onError).toHaveBeenCalledTimes(1);
const err = onError.mock.calls[0][0];
expect(err.code).toBe('NETWORK_ERROR');
expect(err.message).toBe('Network error syncing consent');
});

it('does not fire onError on successful consent sync', async () => {
const queue = createMockQueue();
const send = createMockSend();
const onError = jest.fn();
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError);

manager.setLevel('anonymous');
await Promise.resolve();
await Promise.resolve();

expect(onError).not.toHaveBeenCalled();
});

it('swallows exceptions thrown from the onError callback', async () => {
const queue = createMockQueue();
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({
ok: false,
error: new TransportError({ status: 500, endpoint: 'x', body: null }),
});
const onError = jest.fn().mockImplementation(() => { throw new Error('callback boom'); });
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError);

// Synchronous call must not throw even though the .then() handler will.
expect(() => manager.setLevel('anonymous')).not.toThrow();

await Promise.resolve();
await Promise.resolve();
expect(onError).toHaveBeenCalled();
});
});
});
15 changes: 13 additions & 2 deletions packages/audience/core/src/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
} from './types';
import type { MessageQueue } from './queue';
import type { HttpSend } from './transport';
import { type AudienceError, invokeOnError, toAudienceError } from './errors';
import { CONSENT_PATH, getBaseUrl } from './config';

export interface ConsentManager {
Expand All @@ -29,6 +30,10 @@ export function detectDoNotTrack(): boolean {
* - Fires PUT to `/v1/audience/tracking-consent` on every state change via
* the injected `send`. Sharing the same `HttpSend` instance with the queue
* keeps the transport layer uniform — no module-level mocking required.
* - On consent sync failure, fires `onError` with a public {@link AudienceError}
* mapped via {@link toAudienceError}, so callers don't have to repeat the
* `status === 0 → NETWORK_ERROR` mapping themselves. Exceptions thrown
* from the callback are swallowed.
*/
export function createConsentManager(
queue: MessageQueue,
Expand All @@ -38,6 +43,7 @@ export function createConsentManager(
environment: Environment,
source: string,
initialLevel?: ConsentLevel,
onError?: (err: AudienceError) => void,
): ConsentManager {
const dntDetected = detectDoNotTrack();
let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none');
Expand All @@ -47,8 +53,13 @@ export function createConsentManager(
function notifyBackend(level: ConsentLevel): void {
const url = `${getBaseUrl(environment)}${CONSENT_PATH}`;
const payload: ConsentUpdatePayload = { anonymousId, status: level, source };
// Fire-and-forget. HttpSend never rejects, so a floating promise is safe.
send(url, publishableKey, payload, { method: 'PUT', keepalive: true });
// Fire-and-forget. HttpSend never rejects, so the floating chain is safe.
send(url, publishableKey, payload, { method: 'PUT', keepalive: true })
.then((result) => {
if (!result.ok && result.error) {
invokeOnError(onError, toAudienceError(result.error, 'consent'));
}
});
}

const manager: ConsentManager = {
Expand Down
128 changes: 128 additions & 0 deletions packages/audience/core/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
AudienceError, TransportError, toAudienceError,
} from './errors';

describe('AudienceError', () => {
it('is an instance of Error', () => {
const err = new AudienceError({
code: 'FLUSH_FAILED',
message: 'flush failed',
status: 500,
endpoint: 'https://example.com',
});

expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(AudienceError);
expect(err.name).toBe('AudienceError');
});

it('exposes structured fields from init', () => {
const cause = new TypeError('boom');
const err = new AudienceError({
code: 'NETWORK_ERROR',
message: 'network down',
status: 0,
endpoint: 'https://example.com',
responseBody: { detail: 'x' },
cause,
});

expect(err.code).toBe('NETWORK_ERROR');
expect(err.message).toBe('network down');
expect(err.status).toBe(0);
expect(err.endpoint).toBe('https://example.com');
expect(err.responseBody).toEqual({ detail: 'x' });
expect(err.cause).toBe(cause);
});
});

describe('toAudienceError', () => {
const httpError = new TransportError({
status: 500,
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
body: { code: 'INTERNAL_ERROR' },
});

const networkError = new TransportError({
status: 0,
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
cause: new TypeError('Failed to fetch'),
});

describe('flush source', () => {
it('maps HTTP error to FLUSH_FAILED with status in message', () => {
const err = toAudienceError(httpError, 'flush', 5);

expect(err.code).toBe('FLUSH_FAILED');
expect(err.message).toBe('Flush failed with status 500');
expect(err.status).toBe(500);
expect(err.endpoint).toBe(httpError.endpoint);
expect(err.responseBody).toEqual({ code: 'INTERNAL_ERROR' });
});

it('maps network error to NETWORK_ERROR with batch count in message', () => {
const err = toAudienceError(networkError, 'flush', 5);

expect(err.code).toBe('NETWORK_ERROR');
expect(err.message).toBe('Network error sending 5 messages');
expect(err.status).toBe(0);
expect(err.cause).toBe(networkError.cause);
});

it('falls back to count 0 in network message when count is undefined', () => {
const err = toAudienceError(networkError, 'flush');
expect(err.message).toBe('Network error sending 0 messages');
});
});

describe('consent source', () => {
it('maps HTTP error to CONSENT_SYNC_FAILED with status in message', () => {
const err = toAudienceError(
{ ...httpError, endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent' },
'consent',
);

expect(err.code).toBe('CONSENT_SYNC_FAILED');
expect(err.message).toBe('Consent sync failed with status 500');
});

it('maps network error to NETWORK_ERROR with consent-specific message', () => {
const err = toAudienceError(networkError, 'consent');

expect(err.code).toBe('NETWORK_ERROR');
expect(err.message).toBe('Network error syncing consent');
expect(err.status).toBe(0);
expect(err.cause).toBe(networkError.cause);
});
});

describe('partial-rejection (2xx with rejected > 0)', () => {
it('maps to VALIDATION_REJECTED with backend body preserved', () => {
const partialError = new TransportError({
status: 200,
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
body: { accepted: 50, rejected: 50 },
});

const err = toAudienceError(partialError, 'flush', 100);

expect(err.code).toBe('VALIDATION_REJECTED');
expect(err.status).toBe(200);
expect(err.message).toBe('Backend rejected 50 of 100 messages');
expect(err.responseBody).toEqual({ accepted: 50, rejected: 50 });
});

it('handles missing accepted/rejected fields gracefully', () => {
const partialError = new TransportError({
status: 200,
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
body: {},
});

const err = toAudienceError(partialError, 'flush');

expect(err.code).toBe('VALIDATION_REJECTED');
expect(err.message).toBe('Backend rejected 0 of 0 messages');
});
});
});
Loading
Loading