Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './resources/payment-methods';
export * from './resources/shipping-address';
export * from './resources/user-info';
export * from './resources/web-bot-auth';
export * from './resources/report';
export { MemoryStorage, Storage, storage } from './utils/storage';
export type {
AuthStorage,
Expand Down
162 changes: 162 additions & 0 deletions packages/sdk/src/resources/__tests__/report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { LinkApiError, LinkTransportError } from '@/errors';
import type { CreateReportParams, ReportRecord } from '@/resources/interfaces';
import { ReportResource } from '@/resources/report';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockFetch = vi.fn();
const getAccessToken = vi.fn();

function mockFetchResponse(status: number, body: object) {
mockFetch.mockResolvedValue({
status,
text: async () => JSON.stringify(body),
});
}

const validParams: CreateReportParams = {
domain: 'merchant.com',
outcome: 'blocked',
spend_request_id: 'lsrq_test123',
tags: ['captcha', 'cdn_block'],
step: 'checkout payment form',
freeform_context: 'Challenge appeared after clicking Place Order',
};

const successResponse: ReportRecord = {
object: 'agent_report',
created_at: '2026-05-20T18:30:00Z',
domain: 'merchant.com',
outcome: 'blocked',
spend_request_id: 'lsrq_test123',
status: 'received',
};

describe('ReportResource', () => {
let resource: ReportResource;

beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
vi.clearAllMocks();
getAccessToken.mockResolvedValue('test_token');
resource = new ReportResource({ getAccessToken });
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('create', () => {
it('sends POST to /agent_observations with JSON body and Bearer auth', async () => {
mockFetchResponse(201, successResponse);

await resource.create(validParams);

expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe('https://api.link.com/agent_observations');
expect(opts.method).toBe('POST');
expect(opts.headers['Content-Type']).toBe('application/json');
expect(opts.headers.Authorization).toBe('Bearer test_token');
expect(JSON.parse(opts.body)).toEqual(validParams);
});

it('returns the report record on success', async () => {
mockFetchResponse(201, successResponse);

const result = await resource.create(validParams);

expect(result).toEqual(successResponse);
});

it('works with only required params', async () => {
mockFetchResponse(201, successResponse);

await resource.create({
domain: 'shop.example.com',
outcome: 'success',
spend_request_id: 'lsrq_minimal',
});

const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.domain).toBe('shop.example.com');
expect(body.outcome).toBe('success');
expect(body.spend_request_id).toBe('lsrq_minimal');
expect(body.tags).toBeUndefined();
expect(body.step).toBeUndefined();
expect(body.freeform_context).toBeUndefined();
});

it('retries with refreshed token on 401', async () => {
mockFetch
.mockResolvedValueOnce({ status: 401, text: async () => '{}' })
.mockResolvedValueOnce({
status: 201,
text: async () => JSON.stringify(successResponse),
});
getAccessToken
.mockResolvedValueOnce('expired_token')
.mockResolvedValueOnce('fresh_token');

const result = await resource.create(validParams);

expect(mockFetch).toHaveBeenCalledTimes(2);
const [, secondOpts] = mockFetch.mock.calls[1];
expect(secondOpts.headers.Authorization).toBe('Bearer fresh_token');
expect(result).toEqual(successResponse);
});

it('throws LinkApiError on 400 with error message', async () => {
mockFetchResponse(400, {
error: {
message: 'outcome must be one of: success, blocked, abandoned',
},
});

const err = await resource.create(validParams).catch((e) => e);

expect(err).toBeInstanceOf(LinkApiError);
expect(err.message).toMatch('Failed to create report (400)');
expect(err.message).toMatch('outcome must be one of');
});

it('throws LinkApiError on 404 when flag disabled', async () => {
mockFetchResponse(404, { error: { message: 'Not found' } });

const err = await resource.create(validParams).catch((e) => e);

expect(err).toBeInstanceOf(LinkApiError);
expect(err.message).toMatch('Failed to create report (404)');
});

it('throws when access token is unavailable', async () => {
getAccessToken.mockRejectedValueOnce(new Error('Not authenticated'));

await expect(resource.create(validParams)).rejects.toThrow(
'Not authenticated',
);
});

it('throws LinkTransportError on network failure', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));

const err = await resource.create(validParams).catch((e) => e);

expect(err).toBeInstanceOf(LinkTransportError);
expect(err.message).toMatch('Request failed');
});

it('throws LinkApiError with raw body when error response is not structured', async () => {
mockFetch.mockResolvedValueOnce({
status: 500,
text: async () => 'Internal Server Error',
});

const err = await resource.create(validParams).catch((e) => e);

expect(err).toBeInstanceOf(LinkApiError);
expect(err.message).toMatch('Failed to create report (500)');
expect(err.message).toMatch('Internal Server Error');
});
});
});
22 changes: 22 additions & 0 deletions packages/sdk/src/resources/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,25 @@ export interface IUserInfoResource {
export interface IWebBotAuthResource {
getHeaders(url: string): Promise<WebBotAuthBlock>;
}

export interface CreateReportParams {
domain: string;
outcome: 'success' | 'blocked' | 'abandoned';
spend_request_id: string;
tags?: string[];
step?: string;
freeform_context?: string;
}

export interface ReportRecord {
object: string;
created_at: string;
domain: string;
outcome: string;
spend_request_id: string;
status: string;
}

export interface IReportResource {
create(params: CreateReportParams): Promise<ReportRecord>;
}
124 changes: 124 additions & 0 deletions packages/sdk/src/resources/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
type LinkOptions,
requireFetchImplementation,
resolveLinkSdkConfig,
} from '@/config';
import { LinkApiError, LinkTransportError } from '@/errors';
import type {
AccessTokenProvider,
CreateReportParams,
IReportResource,
ReportRecord,
} from '@/resources/interfaces';

interface ApiFetchOptions {
method: string;
url: string;
headers?: Record<string, string>;
body?: string;
}

export class ReportResource implements IReportResource {
private readonly verbose: boolean;
private readonly getAccessToken: AccessTokenProvider;
private readonly fetchImpl: typeof globalThis.fetch;
private readonly endpoint: string;
private readonly logger: { debug(message: string): void };

constructor(options: LinkOptions) {
const config = resolveLinkSdkConfig(options);
this.verbose = config.verbose;
this.getAccessToken = config.getAccessToken;
this.fetchImpl = requireFetchImplementation(config);
this.endpoint = `${config.apiBaseUrl}/agent_observations`;
this.logger = config.logger;
}

private async rawFetch(
opts: ApiFetchOptions,
): Promise<{ status: number; data: unknown; rawBody: string }> {
if (this.verbose) {
const redactedHeaders = { ...opts.headers };
if (redactedHeaders.Authorization)
redactedHeaders.Authorization = 'Bearer <redacted>';
this.logger.debug(`> ${opts.method} ${opts.url}`);
this.logger.debug(` Headers: ${JSON.stringify(redactedHeaders)}`);
if (opts.body) this.logger.debug(opts.body);
}

let response: Response;
try {
response = await this.fetchImpl(opts.url, {
method: opts.method,
headers: opts.headers,
body: opts.body,
});
} catch (error) {
throw new LinkTransportError(
`Request failed: ${opts.method} ${opts.url}`,
{ cause: error },
);
}
const rawBody = await response.text();

let data: unknown = null;
try {
data = JSON.parse(rawBody);
} catch {
// non-JSON response
}

if (this.verbose) {
this.logger.debug(`< ${response.status} ${response.statusText}`);
this.logger.debug(JSON.stringify(data, null, 2) ?? rawBody);
}

return { status: response.status, data, rawBody };
}

private async apiFetch(
opts: ApiFetchOptions,
): Promise<{ status: number; data: unknown; rawBody: string }> {
const token = await this.getAccessToken();
const authedOpts = {
...opts,
headers: { ...opts.headers, Authorization: `Bearer ${token}` },
};

const res = await this.rawFetch(authedOpts);

if (res.status === 401) {
const refreshedToken = await this.getAccessToken({ forceRefresh: true });
authedOpts.headers.Authorization = `Bearer ${refreshedToken}`;
return this.rawFetch(authedOpts);
}

return res;
}

async create(params: CreateReportParams): Promise<ReportRecord> {
const { status, data, rawBody } = await this.apiFetch({
method: 'POST',
url: this.endpoint,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});

if (status < 200 || status >= 300) {
const message =
data &&
typeof data === 'object' &&
'error' in data &&
typeof (data as Record<string, unknown>).error === 'object'
? ((data as Record<string, { message?: string }>).error?.message ??
rawBody)
: rawBody;
throw new LinkApiError(
`Failed to create report (${status}): ${message}`,
{ status, rawBody, details: data },
);
}

return data as ReportRecord;
}
}
Loading