Skip to content

Commit c46ae87

Browse files
author
op-simoneromeo
authored
Implement Skimlinks affiliate adapter (#313)
1 parent dcce29a commit c46ae87

2 files changed

Lines changed: 375 additions & 26 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,159 @@
11
import { smokeTest } from '@profullstack/sh1pt-core/testing';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
23
import adapter from './index.js';
34

45
smokeTest(adapter, { idPrefix: 'affiliate' });
6+
7+
const ctx = (secrets: Record<string, string> = { SKIMLINKS_CLIENT_SECRET: 'skim-secret' }) => ({
8+
secret: (key: string) => secrets[key],
9+
log: () => {},
10+
});
11+
12+
describe('Skimlinks affiliate adapter', () => {
13+
afterEach(() => {
14+
vi.unstubAllGlobals();
15+
});
16+
17+
it('requires client credentials or a direct token before connecting', async () => {
18+
await expect(adapter.connect(ctx({}), {})).rejects.toThrow(
19+
'SKIMLINKS_CLIENT_ID/clientId and SKIMLINKS_CLIENT_SECRET are required',
20+
);
21+
});
22+
23+
it('verifies credentials through the official authentication API', async () => {
24+
const fetchMock = vi.fn().mockResolvedValue(jsonTextResponse({
25+
access_token: '12345:1553009669:abc123abc123abc123',
26+
}));
27+
vi.stubGlobal('fetch', fetchMock);
28+
29+
await expect(adapter.connect(ctx(), { clientId: 'skim-client', publisherId: '145349' })).resolves.toEqual({
30+
accountId: '145349',
31+
});
32+
33+
const [url, request] = fetchMock.mock.calls[0]!;
34+
expect(String(url)).toBe('https://authentication.skimapis.com/access_token');
35+
expect(request.method).toBe('POST');
36+
expect(JSON.parse(request.body)).toEqual({
37+
client_id: 'skim-client',
38+
client_secret: 'skim-secret',
39+
grant_type: 'client_credentials',
40+
});
41+
});
42+
43+
it('builds Link Wrapper URLs with sref and custom tracking', async () => {
44+
await expect(adapter.getTrackingLink?.(
45+
ctx({ SKIMLINKS_ACCESS_TOKEN: 'direct-token' }),
46+
'6143',
47+
'https://merchant.example/product?a=1',
48+
{ domainId: '123X456', sref: 'https://publisher.example/post', customId: 'campaign-1' },
49+
)).resolves.toEqual({
50+
url: 'https://go.skimresources.com/?id=123X456&url=https%3A%2F%2Fmerchant.example%2Fproduct%3Fa%3D1&sref=https%3A%2F%2Fpublisher.example%2Fpost&xcust=campaign-1',
51+
});
52+
});
53+
54+
it('requires a domain id and destination URL for Link Wrapper URLs', async () => {
55+
await expect(adapter.getTrackingLink?.(
56+
ctx({ SKIMLINKS_ACCESS_TOKEN: 'direct-token' }),
57+
'6143',
58+
'https://merchant.example/product',
59+
{},
60+
)).rejects.toThrow('Skimlinks domainId is required');
61+
await expect(adapter.getTrackingLink?.(
62+
ctx({ SKIMLINKS_ACCESS_TOKEN: 'direct-token' }),
63+
'6143',
64+
'',
65+
{ domainId: '123X456' },
66+
)).rejects.toThrow('Skimlinks destinationUrl is required');
67+
});
68+
69+
it('aggregates Reporting API merchant stats using authenticated access tokens', async () => {
70+
const fetchMock = vi.fn()
71+
.mockResolvedValueOnce(jsonTextResponse({ access_token: '12345:1553009669:abc123abc123abc123' }))
72+
.mockResolvedValueOnce(jsonTextResponse({
73+
count: 1,
74+
reports: [{
75+
clicks_affiliated: 5,
76+
order_amount: 70,
77+
publisher_commission_amount: 7,
78+
sales: 2,
79+
}],
80+
totals: {
81+
clicks_affiliated: 11,
82+
order_amount: 100,
83+
publisher_commission_amount: 9.5,
84+
sales: 3,
85+
},
86+
}));
87+
vi.stubGlobal('fetch', fetchMock);
88+
89+
await expect(adapter.stats?.(ctx(), '6143', {
90+
clientId: 'skim-client',
91+
currency: 'EUR',
92+
from: '2026-05-01',
93+
publisherDomainId: '22',
94+
publisherId: '145349',
95+
to: '2026-05-20',
96+
})).resolves.toEqual({
97+
publishers: 1,
98+
clicks: 11,
99+
conversions: 3,
100+
revenue: 100,
101+
commissionsPaid: 9.5,
102+
currency: 'EUR',
103+
});
104+
105+
const [url] = fetchMock.mock.calls[1]!;
106+
expect(String(url)).toBe(
107+
'https://reporting.skimapis.com/publisher/145349/reports?access_token=12345%3A1553009669%3Aabc123abc123abc123&report_by=merchant&start_date=2026-05-01&end_date=2026-05-20&sort_by=publisher_commission_amount&sort_dir=DESC&currency=EUR&a_id=6143&domain_id=22',
108+
);
109+
});
110+
111+
it('falls back to summing report rows when totals are omitted', async () => {
112+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonTextResponse({
113+
reports: [
114+
{ clicks_affiliated: 2, sales: 1, order_amount: '10.50', publisher_commission_amount: '1.25' },
115+
{ clicks_affiliated: 3, sales: 2, order_amount: 20, publisher_commission_amount: 2 },
116+
],
117+
})));
118+
119+
await expect(adapter.stats?.(
120+
ctx({ SKIMLINKS_ACCESS_TOKEN: 'direct-token' }),
121+
'6143',
122+
{ publisherId: '145349' },
123+
)).resolves.toEqual({
124+
publishers: 1,
125+
clicks: 5,
126+
conversions: 3,
127+
revenue: 30.5,
128+
commissionsPaid: 3.25,
129+
currency: 'USD',
130+
});
131+
});
132+
133+
it('requires a publisher id for Reporting API stats', async () => {
134+
await expect(adapter.stats?.(
135+
ctx({ SKIMLINKS_ACCESS_TOKEN: 'direct-token' }),
136+
'6143',
137+
{},
138+
)).rejects.toThrow('Skimlinks publisherId/accountId is required');
139+
});
140+
141+
it('surfaces provider errors without leaking credentials or access tokens', async () => {
142+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
143+
ok: false,
144+
status: 401,
145+
text: async () => 'bad skim-client skim-secret 12345:1553009669:abc123abc123abc123',
146+
}));
147+
148+
await expect(adapter.connect(ctx(), { clientId: 'skim-client' })).rejects.toThrow(
149+
'bad [redacted] [redacted] [redacted-token]',
150+
);
151+
});
152+
});
153+
154+
function jsonTextResponse(body: unknown) {
155+
return {
156+
ok: true,
157+
text: async () => JSON.stringify(body),
158+
};
159+
}

0 commit comments

Comments
 (0)