Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions packages/audience/core/src/consent.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { createConsentManager } from './consent';
import { httpSend } from './transport';
import type { HttpSend } from './transport';

jest.mock('./transport', () => ({
httpSend: jest.fn().mockResolvedValue(true),
}));

const mockHttpSend = httpSend as jest.MockedFunction<typeof httpSend>;
function createMockSend(): jest.MockedFunction<HttpSend> {
return jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({ ok: true });
}

function createMockQueue() {
return {
Expand All @@ -29,19 +27,22 @@ beforeEach(() => {
describe('createConsentManager', () => {
it('defaults to none when no initial level provided', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel');
const send = createMockSend();
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel');
expect(manager.level).toBe('none');
});

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

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

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

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

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

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

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

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

manager.setLevel('anonymous');

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

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

manager.setLevel('anonymous');
expect(queue.purge).not.toHaveBeenCalled();
expect(queue.transform).not.toHaveBeenCalled();
expect(mockHttpSend).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});

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

const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel');
const send = createMockSend();
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel');
expect(manager.level).toBe('none');

Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
Expand Down
10 changes: 7 additions & 3 deletions packages/audience/core/src/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type {
ConsentLevel, ConsentUpdatePayload, Message, Environment,
} from './types';
import type { MessageQueue } from './queue';
import type { HttpSend } from './transport';
import { CONSENT_PATH, getBaseUrl } from './config';
import { httpSend } from './transport';

export interface ConsentManager {
level: ConsentLevel;
Expand All @@ -26,10 +26,13 @@ export function detectDoNotTrack(): boolean {
* - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`.
* - On downgrade (e.g. full -> anonymous), strips `userId` from queued messages.
* - On downgrade to `'none'`, purges the queue entirely.
* - Fires PUT to `/v1/audience/tracking-consent` on every state change.
* - 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.
*/
export function createConsentManager(
queue: MessageQueue,
send: HttpSend,
publishableKey: string,
anonymousId: string,
environment: Environment,
Expand All @@ -44,7 +47,8 @@ export function createConsentManager(
function notifyBackend(level: ConsentLevel): void {
const url = `${getBaseUrl(environment)}${CONSENT_PATH}`;
const payload: ConsentUpdatePayload = { anonymousId, status: level, source };
httpSend(url, publishableKey, payload, { method: 'PUT', keepalive: true });
// Fire-and-forget. HttpSend never rejects, so a floating promise is safe.
send(url, publishableKey, payload, { method: 'PUT', keepalive: true });
}

const manager: ConsentManager = {
Expand Down
56 changes: 56 additions & 0 deletions packages/audience/core/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Structured error returned by the transport layer when a send fails.
*
* Extends native {@link Error} so stack traces are captured at the
* construction site and instances flow through standard error-reporting
* tools (browser devtools, Sentry, `@imtbl/metrics`) without custom
* handling.
*
* - `status` is the HTTP status code on a protocol-level error, or `0`
* when the fetch itself rejected (network failure, CORS, DNS).
* - `endpoint` is the full URL that was called.
* - `body` is the parsed response body when content-type was JSON,
* the raw string when not, or undefined when the response had no
* parseable body (e.g. a network failure).
* - `cause` (inherited from {@link Error}, ES2022) is the original error
* object (`TypeError`, `DOMException`, etc.) on a network failure;
* undefined on an HTTP error.
*/
export class TransportError extends Error {
readonly status: number;

readonly endpoint: string;

readonly body?: unknown;

constructor(init: {
status: number;
endpoint: string;
body?: unknown;
cause?: unknown;
}) {
super(
`audience transport failed: ${init.status || 'network error'} ${init.endpoint}`,
init.cause !== undefined ? { cause: init.cause } : undefined,
);
this.name = 'TransportError';
this.status = init.status;
this.endpoint = init.endpoint;
this.body = init.body;
}
}

/**
* Return type of every transport send.
*
* `ok: true` means the backend accepted the payload (HTTP 2xx). On
* `ok: false`, `error` is always populated with a structured reason.
*
* Implementations of `HttpSend` MUST NOT reject — failures travel
* through this result type. Callers (notably `MessageQueue.flushUnload`)
* rely on this contract for fire-and-forget paths.
*/
export interface TransportResult {
ok: boolean;
error?: TransportError;
}
5 changes: 3 additions & 2 deletions packages/audience/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ export {

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

export type { Transport, TransportOptions } from './transport';
export { httpTransport, httpSend } from './transport';
export type { HttpSend, TransportOptions } from './transport';
export { httpSend } from './transport';
export type { TransportError, TransportResult } from './errors';
export { MessageQueue } from './queue';
export { collectContext } from './context';
export {
Expand Down
Loading
Loading