@bhooai/nexus-ads 2.0.17 → 2.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -8
- package/package.json +2 -1
- package/src/AdSenseClient.ts +210 -0
- package/src/ReportBuilder.ts +99 -0
- package/src/adsenseHtml.ts +75 -0
- package/src/index.ts +10 -9
- package/src/oauth.ts +96 -0
- package/src/types.ts +53 -18
- package/tests/adsense.test.ts +209 -0
- package/tests/oauth.test.ts +56 -0
- package/vitest.config.ts +1 -1
- package/src/GaqlBuilder.ts +0 -72
- package/src/GoogleAdsClient.ts +0 -121
- package/tests/ads.test.ts +0 -127
package/README.md
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
# @bhooai/nexus-ads
|
|
2
2
|
|
|
3
|
-
Google
|
|
4
|
-
reporting builder.
|
|
3
|
+
Google AdSense module (publisher-side): AdSense Management API v2 client,
|
|
4
|
+
reporting builder, and frontend display-snippet helpers.
|
|
5
5
|
|
|
6
6
|
## Exports
|
|
7
7
|
|
|
8
|
-
- `
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
-
|
|
8
|
+
- `createAdSense(config, opts)` → `AdSenseClient`.
|
|
9
|
+
- `AdSenseClient` — `listAccounts()`, `listAdClients()`, `listSites()`,
|
|
10
|
+
`listAdUnits()`, `generateReport()`, `earningsReport()`.
|
|
11
|
+
- **ReportBuilder** — fluent reporting params + `earningsReportOptions()`,
|
|
12
|
+
`topPagesReportOptions()`.
|
|
13
|
+
- `adsenseScript()`, `autoAdsScript()`, `adUnit()`, `normalizeAdClient()` —
|
|
14
|
+
framework-agnostic HTML strings (work with `nexus-future` `html`/`raw`).
|
|
15
|
+
- `fetchTransport` — the default transport helper.
|
|
16
|
+
- Types for config, accounts, ad clients, sites, ad units, reports.
|
|
12
17
|
|
|
13
|
-
OAuth2 credentials are configured via
|
|
14
|
-
|
|
18
|
+
OAuth2 credentials (`adsense.readonly` scope) are configured via
|
|
19
|
+
`NEXUS_ADS_*` / `nexus.config.ts` (`ads: { enabled, publisherId, clientId,
|
|
20
|
+
clientSecret, refreshToken, accountId? }`). Tests mock the HTTP transport
|
|
21
|
+
(no live AdSense calls).
|
|
22
|
+
|
|
23
|
+
### OAuth consent flow (obtain a refresh token)
|
|
24
|
+
|
|
25
|
+
- `buildAuthorizeUrl({ clientId, redirectUri, state? })` — consent URL with
|
|
26
|
+
`access_type=offline` + `prompt=consent` so a refresh token is issued.
|
|
27
|
+
`redirectUri` must be registered on the Google Cloud OAuth web client.
|
|
28
|
+
- `exchangeCode({ clientId, clientSecret, code, redirectUri })` — exchange the
|
|
29
|
+
returned `?code=...` for tokens; persist `refreshToken` as
|
|
30
|
+
`NEXUS_ADS_REFRESH_TOKEN` (gitignored `.env`, never in git).
|
|
31
|
+
|
|
32
|
+
### Display snippet CSP
|
|
33
|
+
|
|
34
|
+
Allow `script-src https://pagead2.googlesyndication.com` and
|
|
35
|
+
`frame-src https://googleads.g.doubleclick.net`. For GDPR, gate the loader
|
|
36
|
+
behind consent and consider `data-npa-on-unknown-consent`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdClient,
|
|
3
|
+
AdSenseAccount,
|
|
4
|
+
AdSenseConfig,
|
|
5
|
+
AdSenseSite,
|
|
6
|
+
AdUnit,
|
|
7
|
+
HttpTransport,
|
|
8
|
+
ReportOptions,
|
|
9
|
+
ReportResult,
|
|
10
|
+
ReportRow,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
14
|
+
const ADSENSE_BASE = 'https://adsense.googleapis.com/v2';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* AdSense Management API v2 client (publisher-side, REST only, no native deps).
|
|
18
|
+
*
|
|
19
|
+
* OAuth2 access tokens are acquired via the refresh-token grant
|
|
20
|
+
* (`adsense.readonly` scope must be granted to the refresh token) and cached
|
|
21
|
+
* until near expiry. The HTTP transport is injectable so tests run without
|
|
22
|
+
* live credentials.
|
|
23
|
+
*/
|
|
24
|
+
export class AdSenseClient {
|
|
25
|
+
private readonly cfg: AdSenseConfig;
|
|
26
|
+
private readonly transport: HttpTransport;
|
|
27
|
+
private token: { value: string; expiresAt: number } | null = null;
|
|
28
|
+
|
|
29
|
+
constructor(cfg: AdSenseConfig, transport: HttpTransport) {
|
|
30
|
+
this.cfg = cfg;
|
|
31
|
+
this.transport = transport;
|
|
32
|
+
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
|
|
33
|
+
throw new Error('[nexus-ads] clientId, clientSecret, refreshToken all required');
|
|
34
|
+
}
|
|
35
|
+
if (!cfg.publisherId && !cfg.accountId) {
|
|
36
|
+
throw new Error('[nexus-ads] publisherId (or accountId) is required');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** OAuth2 refresh-token → access token, cached with a 60s safety margin. */
|
|
41
|
+
private async accessToken(): Promise<string> {
|
|
42
|
+
if (this.token && Date.now() < this.token.expiresAt - 60_000) return this.token.value;
|
|
43
|
+
const body = new URLSearchParams({
|
|
44
|
+
client_id: this.cfg.clientId,
|
|
45
|
+
client_secret: this.cfg.clientSecret,
|
|
46
|
+
refresh_token: this.cfg.refreshToken,
|
|
47
|
+
grant_type: 'refresh_token',
|
|
48
|
+
}).toString();
|
|
49
|
+
const res = await this.transport({
|
|
50
|
+
method: 'POST',
|
|
51
|
+
url: TOKEN_URL,
|
|
52
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
53
|
+
body,
|
|
54
|
+
});
|
|
55
|
+
const parsed = safeJson(res.body);
|
|
56
|
+
if (res.status !== 200 || !parsed?.access_token) {
|
|
57
|
+
throw new Error(`[nexus-ads] token refresh failed: HTTP ${res.status} ${res.body}`);
|
|
58
|
+
}
|
|
59
|
+
this.token = { value: parsed.access_token, expiresAt: Date.now() + (parsed.expires_in ?? 3600) * 1000 };
|
|
60
|
+
return this.token.value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async authHeaders(): Promise<Record<string, string>> {
|
|
64
|
+
return { authorization: `Bearer ${await this.accessToken()}` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async get<T>(path: string, query?: Record<string, string | undefined>): Promise<T> {
|
|
68
|
+
const headers = await this.authHeaders();
|
|
69
|
+
const qs = queryString(query);
|
|
70
|
+
const res = await this.transport({
|
|
71
|
+
method: 'GET',
|
|
72
|
+
url: `${ADSENSE_BASE}${path}${qs ? `?${qs}` : ''}`,
|
|
73
|
+
headers,
|
|
74
|
+
});
|
|
75
|
+
if (res.status !== 200) {
|
|
76
|
+
throw new Error(`[nexus-ads] GET ${path} failed: HTTP ${res.status} ${res.body}`);
|
|
77
|
+
}
|
|
78
|
+
return safeJson(res.body) as T;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async listAll<T>(path: string, query?: Record<string, string | undefined>, pageSize = 50): Promise<T[]> {
|
|
82
|
+
const items: T[] = [];
|
|
83
|
+
let pageToken: string | undefined;
|
|
84
|
+
do {
|
|
85
|
+
const page = await this.get<{ [k: string]: unknown }>(path, {
|
|
86
|
+
...query,
|
|
87
|
+
pageSize: String(pageSize),
|
|
88
|
+
...(pageToken ? { pageToken } : {}),
|
|
89
|
+
});
|
|
90
|
+
const batch = extractList(page);
|
|
91
|
+
if (batch.length) items.push(...(batch as T[]));
|
|
92
|
+
pageToken = typeof page.nextPageToken === 'string' ? page.nextPageToken : undefined;
|
|
93
|
+
} while (pageToken);
|
|
94
|
+
return items;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Default account: `accountId` config or derived from `publisherId`. */
|
|
98
|
+
defaultAccount(): string {
|
|
99
|
+
const raw = this.cfg.accountId ?? this.cfg.publisherId;
|
|
100
|
+
return toAccountName(raw);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** List AdSense accounts visible to the OAuth grant. */
|
|
104
|
+
async listAccounts(pageSize = 50): Promise<AdSenseAccount[]> {
|
|
105
|
+
return this.listAll<AdSenseAccount>('/accounts', undefined, pageSize);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** List ad clients under an account. Defaults to the configured account. */
|
|
109
|
+
async listAdClients(account = this.defaultAccount(), pageSize = 50): Promise<AdClient[]> {
|
|
110
|
+
return this.listAll<AdClient>(`/${account}/adclients`, undefined, pageSize);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** List verified sites under an account. */
|
|
114
|
+
async listSites(account = this.defaultAccount(), pageSize = 50): Promise<AdSenseSite[]> {
|
|
115
|
+
return this.listAll<AdSenseSite>(`/${account}/sites`, undefined, pageSize);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** List ad units. `adClient` is `ca-pub-xxx` or the full resource name. */
|
|
119
|
+
async listAdUnits(account = this.defaultAccount(), adClient?: string, pageSize = 50): Promise<AdUnit[]> {
|
|
120
|
+
const query: Record<string, string | undefined> = {};
|
|
121
|
+
if (adClient) query.adClientName = toAdClientName(account, adClient);
|
|
122
|
+
return this.listAll<AdUnit>(`/${account}/adunits`, query, pageSize);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Generate an earnings/traffic report for a date range (YYYY-MM-DD). */
|
|
126
|
+
async generateReport(opts: ReportOptions, account = this.defaultAccount()): Promise<ReportResult> {
|
|
127
|
+
if (!opts.startDate || !opts.endDate) {
|
|
128
|
+
throw new Error('[nexus-ads] generateReport: startDate and endDate (YYYY-MM-DD) required');
|
|
129
|
+
}
|
|
130
|
+
const query: Record<string, string | undefined> = {
|
|
131
|
+
'dateRange.startDate.year': undefined,
|
|
132
|
+
...(opts.dimensions?.length ? { dimensions: opts.dimensions.join(',') } : {}),
|
|
133
|
+
...(opts.metrics?.length ? { metrics: opts.metrics.join(',') } : {}),
|
|
134
|
+
startDate: opts.startDate,
|
|
135
|
+
endDate: opts.endDate,
|
|
136
|
+
...(opts.filters?.length ? { filters: opts.filters.join(',') } : {}),
|
|
137
|
+
...(opts.orderBy?.length ? { orderBy: opts.orderBy.join(',') } : {}),
|
|
138
|
+
...(opts.limit != null ? { limit: String(opts.limit) } : {}),
|
|
139
|
+
};
|
|
140
|
+
// Remove the placeholder key; keep query building explicit for readability.
|
|
141
|
+
delete query['dateRange.startDate.year'];
|
|
142
|
+
const raw = await this.get<Record<string, unknown>>(`/${account}/reports:generate`, query);
|
|
143
|
+
const rows = Array.isArray(raw.rows) ? (raw.rows as ReportRow[]) : [];
|
|
144
|
+
const totals = (raw.totals as ReportRow | undefined) ?? undefined;
|
|
145
|
+
const headers = Array.isArray(raw.headers)
|
|
146
|
+
? (raw.headers as Array<{ name: string; type?: string }>)
|
|
147
|
+
: undefined;
|
|
148
|
+
return { rows, totals, headers };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Earnings report convenience (totals + daily rows when DATE dimension used). */
|
|
152
|
+
async earningsReport(fromDate: string, toDate: string, account = this.defaultAccount(), limit = 50): Promise<ReportResult> {
|
|
153
|
+
return this.generateReport(
|
|
154
|
+
{
|
|
155
|
+
dimensions: ['DATE'],
|
|
156
|
+
metrics: ['PAGE_VIEWS', 'AD_REQUESTS', 'CLICKS', 'ESTIMATED_EARNINGS'],
|
|
157
|
+
startDate: fromDate,
|
|
158
|
+
endDate: toDate,
|
|
159
|
+
orderBy: ['+DATE'],
|
|
160
|
+
limit,
|
|
161
|
+
},
|
|
162
|
+
account,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `pub-xxx` → `accounts/pub-xxx`; passes through full resource names. */
|
|
168
|
+
export function toAccountName(raw: string): string {
|
|
169
|
+
const v = raw.trim();
|
|
170
|
+
if (v.startsWith('accounts/')) return v;
|
|
171
|
+
return `accounts/${v.replace(/^ca-/, '')}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `ca-pub-xxx` (or bare) → `accounts/pub-xxx/adclients/ca-pub-xxx`. */
|
|
175
|
+
export function toAdClientName(account: string, adClient: string): string {
|
|
176
|
+
const c = adClient.trim();
|
|
177
|
+
if (c.includes('/adclients/')) return c;
|
|
178
|
+
const clientId = c.startsWith('ca-') ? c : `ca-${c.replace(/^pub-/, 'pub-')}`;
|
|
179
|
+
return `${account}/adclients/${clientId}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function queryString(query?: Record<string, string | undefined>): string {
|
|
183
|
+
if (!query) return '';
|
|
184
|
+
const params = new URLSearchParams();
|
|
185
|
+
for (const [k, v] of Object.entries(query)) {
|
|
186
|
+
if (v !== undefined && v !== '') params.append(k, v);
|
|
187
|
+
}
|
|
188
|
+
return params.toString();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** AdSense list endpoints nest items under different keys; accept any array field. */
|
|
192
|
+
function extractList(page: Record<string, unknown>): unknown[] {
|
|
193
|
+
for (const key of ['accounts', 'adClients', 'sites', 'adUnits']) {
|
|
194
|
+
const v = page[key];
|
|
195
|
+
if (Array.isArray(v)) return v;
|
|
196
|
+
}
|
|
197
|
+
// Fallback: first array-valued property.
|
|
198
|
+
for (const v of Object.values(page)) {
|
|
199
|
+
if (Array.isArray(v)) return v;
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function safeJson(body: string): any {
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(body);
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ReportOptions } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fluent builder for AdSense `reports:generate` parameters.
|
|
5
|
+
*
|
|
6
|
+
* Produces e.g.:
|
|
7
|
+
* dimensions=DATE,COUNTRY_NAME&metrics=PAGE_VIEWS,ESTIMATED_EARNINGS
|
|
8
|
+
* &startDate=2026-01-01&endDate=2026-01-31&orderBy=%2BDATE&limit=50
|
|
9
|
+
*/
|
|
10
|
+
export class ReportBuilder {
|
|
11
|
+
private dimensions: string[] = [];
|
|
12
|
+
private metrics: string[] = [];
|
|
13
|
+
private filters: string[] = [];
|
|
14
|
+
private order: string[] = [];
|
|
15
|
+
private start?: string;
|
|
16
|
+
private end?: string;
|
|
17
|
+
private limitN?: number;
|
|
18
|
+
|
|
19
|
+
dimension(...dims: string[]): this {
|
|
20
|
+
this.dimensions.push(...dims);
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
metric(...metrics: string[]): this {
|
|
24
|
+
this.metrics.push(...metrics);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
range(startDate: string, endDate: string): this {
|
|
28
|
+
this.start = startDate;
|
|
29
|
+
this.end = endDate;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
/** Add a filter, e.g. `filter("COUNTRY_NAME==India")`. */
|
|
33
|
+
filter(condition: string): this {
|
|
34
|
+
this.filters.push(condition);
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
orderBy(...fields: string[]): this {
|
|
38
|
+
this.order.push(...fields);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
limit(n: number): this {
|
|
42
|
+
this.limitN = n;
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
build(): ReportOptions {
|
|
47
|
+
if (!this.start || !this.end) {
|
|
48
|
+
throw new Error('[nexus-ads] ReportBuilder: date range required (call .range(from, to))');
|
|
49
|
+
}
|
|
50
|
+
if (this.metrics.length === 0) {
|
|
51
|
+
throw new Error('[nexus-ads] ReportBuilder: at least one metric required');
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
...(this.dimensions.length ? { dimensions: [...this.dimensions] } : {}),
|
|
55
|
+
metrics: [...this.metrics],
|
|
56
|
+
startDate: this.start,
|
|
57
|
+
endDate: this.end,
|
|
58
|
+
...(this.filters.length ? { filters: [...this.filters] } : {}),
|
|
59
|
+
...(this.order.length ? { orderBy: [...this.order] } : {}),
|
|
60
|
+
...(this.limitN != null ? { limit: this.limitN } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Encode the built options as a URL query string (for logging/debugging). */
|
|
65
|
+
toQueryString(): string {
|
|
66
|
+
const opts = this.build();
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
if (opts.dimensions?.length) params.append('dimensions', opts.dimensions.join(','));
|
|
69
|
+
params.append('metrics', opts.metrics!.join(','));
|
|
70
|
+
params.append('startDate', opts.startDate);
|
|
71
|
+
params.append('endDate', opts.endDate);
|
|
72
|
+
if (opts.filters?.length) params.append('filters', opts.filters.join(','));
|
|
73
|
+
if (opts.orderBy?.length) params.append('orderBy', opts.orderBy.join(','));
|
|
74
|
+
if (opts.limit != null) params.append('limit', String(opts.limit));
|
|
75
|
+
return params.toString();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Convenience: daily earnings report options for a date range (YYYY-MM-DD). */
|
|
80
|
+
export function earningsReportOptions(fromDate: string, toDate: string, limit = 50): ReportOptions {
|
|
81
|
+
return new ReportBuilder()
|
|
82
|
+
.dimension('DATE')
|
|
83
|
+
.metric('PAGE_VIEWS', 'AD_REQUESTS', 'CLICKS', 'ESTIMATED_EARNINGS')
|
|
84
|
+
.range(fromDate, toDate)
|
|
85
|
+
.orderBy('+DATE')
|
|
86
|
+
.limit(limit)
|
|
87
|
+
.build();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Convenience: top-pages report options for a date range (YYYY-MM-DD). */
|
|
91
|
+
export function topPagesReportOptions(fromDate: string, toDate: string, limit = 50): ReportOptions {
|
|
92
|
+
return new ReportBuilder()
|
|
93
|
+
.dimension('PAGE_URL')
|
|
94
|
+
.metric('PAGE_VIEWS', 'CLICKS', 'ESTIMATED_EARNINGS')
|
|
95
|
+
.range(fromDate, toDate)
|
|
96
|
+
.orderBy('-ESTIMATED_EARNINGS')
|
|
97
|
+
.limit(limit)
|
|
98
|
+
.build();
|
|
99
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic AdSense display-snippet helpers.
|
|
3
|
+
*
|
|
4
|
+
* Returns plain HTML strings — safe to embed with `nexus-future` `html`/`raw`,
|
|
5
|
+
* any SSR pipeline, or `innerHTML`. All dynamic values are attribute-escaped.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface AdSenseScriptOptions {
|
|
9
|
+
/** Defaults to true (`async` attribute). */
|
|
10
|
+
async?: boolean;
|
|
11
|
+
/** Defaults to `anonymous`. Pass `false` to omit. */
|
|
12
|
+
crossorigin?: string | false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AdUnitOptions {
|
|
16
|
+
/** Publisher client, `ca-pub-xxx` (or `pub-xxx`, normalized automatically). */
|
|
17
|
+
adClient: string;
|
|
18
|
+
/** Ad unit slot id from the AdSense dashboard. */
|
|
19
|
+
adSlot: string;
|
|
20
|
+
/** Defaults to `auto`. Use `fluid`, `rectangle`, etc. as needed. */
|
|
21
|
+
format?: string;
|
|
22
|
+
/** Defaults to true (`data-full-width-responsive="true"`). */
|
|
23
|
+
responsive?: boolean;
|
|
24
|
+
/** Extra CSS for the `<ins>` tag. Defaults to `display:block`. */
|
|
25
|
+
style?: string;
|
|
26
|
+
/** Layout for in-article / in-feed units (e.g. `in-article`). */
|
|
27
|
+
layout?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `pub-xxx` → `ca-pub-xxx`; passes through values already prefixed. */
|
|
31
|
+
export function normalizeAdClient(raw: string): string {
|
|
32
|
+
const v = raw.trim();
|
|
33
|
+
if (v.startsWith('ca-pub-')) return v;
|
|
34
|
+
if (v.startsWith('pub-')) return `ca-${v}`;
|
|
35
|
+
return `ca-pub-${v}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `<script>` loader for `pagead2.googlesyndication.com`. Include once per page. */
|
|
39
|
+
export function adsenseScript(publisherId: string, opts: AdSenseScriptOptions = {}): string {
|
|
40
|
+
const client = normalizeAdClient(publisherId);
|
|
41
|
+
const asyncAttr = opts.async === false ? '' : ' async';
|
|
42
|
+
const crossorigin =
|
|
43
|
+
opts.crossorigin === false ? '' : ` crossorigin="${escapeAttr(opts.crossorigin ?? 'anonymous')}"`;
|
|
44
|
+
return `<script${asyncAttr} src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${escapeAttr(client)}"${crossorigin}></script>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Auto-ads variant (same loader; kept as a named helper for intent). */
|
|
48
|
+
export function autoAdsScript(publisherId: string, opts: AdSenseScriptOptions = {}): string {
|
|
49
|
+
return adsenseScript(publisherId, opts);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Manual ad unit: `<ins class="adsbygoogle">` + push snippet. */
|
|
53
|
+
export function adUnit(opts: AdUnitOptions): string {
|
|
54
|
+
if (!opts.adClient) throw new Error('[nexus-ads] adUnit: adClient is required');
|
|
55
|
+
if (!opts.adSlot) throw new Error('[nexus-ads] adUnit: adSlot is required');
|
|
56
|
+
const client = normalizeAdClient(opts.adClient);
|
|
57
|
+
const format = opts.format ?? 'auto';
|
|
58
|
+
const responsive = opts.responsive === false ? '' : ' data-full-width-responsive="true"';
|
|
59
|
+
const style = escapeAttr(opts.style ?? 'display:block');
|
|
60
|
+
const layout = opts.layout ? ` data-layout="${escapeAttr(opts.layout)}"` : '';
|
|
61
|
+
const ins =
|
|
62
|
+
`<ins class="adsbygoogle" style="${style}"` +
|
|
63
|
+
` data-ad-client="${escapeAttr(client)}"` +
|
|
64
|
+
` data-ad-slot="${escapeAttr(opts.adSlot)}"` +
|
|
65
|
+
` data-ad-format="${escapeAttr(format)}"${layout}${responsive}></ins>`;
|
|
66
|
+
return `${ins}\n<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function escapeAttr(value: string): string {
|
|
70
|
+
return value
|
|
71
|
+
.replace(/&/g, '&')
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.replace(/</g, '<')
|
|
74
|
+
.replace(/>/g, '>');
|
|
75
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
|
-
export * from './
|
|
3
|
-
export * from './
|
|
2
|
+
export * from './ReportBuilder.js';
|
|
3
|
+
export * from './AdSenseClient.js';
|
|
4
|
+
export * from './adsenseHtml.js';
|
|
5
|
+
export * from './oauth.js';
|
|
4
6
|
|
|
5
|
-
import {
|
|
7
|
+
import { AdSenseClient } from './AdSenseClient.js';
|
|
6
8
|
import { fetchTransport } from './fetchTransport.js';
|
|
7
|
-
import type {
|
|
9
|
+
import type { AdSenseConfig, HttpTransport } from './types.js';
|
|
8
10
|
import { requireLicense } from '@bhooai/nexus-crypto';
|
|
9
11
|
|
|
10
12
|
/** Default fetch-based transport (Node >= 18). */
|
|
@@ -12,12 +14,11 @@ export { fetchTransport };
|
|
|
12
14
|
|
|
13
15
|
export interface CreateAdsOptions {
|
|
14
16
|
transport?: HttpTransport;
|
|
15
|
-
apiVersion?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/** Build
|
|
19
|
-
export function
|
|
20
|
-
// Licensed feature: the
|
|
19
|
+
/** Build an AdSense client. Throws at construction if required creds are missing. */
|
|
20
|
+
export function createAdSense(config: AdSenseConfig, opts: CreateAdsOptions = {}): AdSenseClient {
|
|
21
|
+
// Licensed feature: the AdSense client needs a master key.
|
|
21
22
|
requireLicense('nexus-ads');
|
|
22
|
-
return new
|
|
23
|
+
return new AdSenseClient(config, opts.transport ?? fetchTransport);
|
|
23
24
|
}
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { HttpTransport } from './types.js';
|
|
2
|
+
import { fetchTransport } from './fetchTransport.js';
|
|
3
|
+
|
|
4
|
+
export const AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
5
|
+
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
6
|
+
|
|
7
|
+
/** OAuth2 scope for the AdSense Management API (read-only reporting). */
|
|
8
|
+
export const ADSENSE_READONLY_SCOPE = 'https://www.googleapis.com/auth/adsense.readonly';
|
|
9
|
+
|
|
10
|
+
export interface AuthorizeUrlInput {
|
|
11
|
+
/** OAuth2 web client ID (Google Cloud console → Credentials). */
|
|
12
|
+
clientId: string;
|
|
13
|
+
/** Must match a redirect URI registered on the OAuth client. */
|
|
14
|
+
redirectUri: string;
|
|
15
|
+
/** Extra scopes (adsense.readonly is always included). */
|
|
16
|
+
scopes?: string[];
|
|
17
|
+
/** Opaque CSRF state round-tripped through the consent redirect. */
|
|
18
|
+
state?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the Google consent URL that issues an authorization code for the
|
|
23
|
+
* AdSense Management API. `access_type=offline` + `prompt=consent` ensure a
|
|
24
|
+
* long-lived refresh token is returned on exchange.
|
|
25
|
+
*/
|
|
26
|
+
export function buildAuthorizeUrl(input: AuthorizeUrlInput): string {
|
|
27
|
+
if (!input.clientId) throw new Error('[nexus-ads] buildAuthorizeUrl: clientId required');
|
|
28
|
+
if (!input.redirectUri) throw new Error('[nexus-ads] buildAuthorizeUrl: redirectUri required');
|
|
29
|
+
const params = new URLSearchParams({
|
|
30
|
+
client_id: input.clientId,
|
|
31
|
+
redirect_uri: input.redirectUri,
|
|
32
|
+
response_type: 'code',
|
|
33
|
+
access_type: 'offline',
|
|
34
|
+
prompt: 'consent',
|
|
35
|
+
scope: [ADSENSE_READONLY_SCOPE, ...(input.scopes ?? [])].join(' '),
|
|
36
|
+
});
|
|
37
|
+
if (input.state) params.set('state', input.state);
|
|
38
|
+
return `${AUTHORIZE_URL}?${params.toString()}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ExchangeCodeInput {
|
|
42
|
+
clientId: string;
|
|
43
|
+
clientSecret: string;
|
|
44
|
+
/** Authorization code from the consent redirect (`?code=...`). */
|
|
45
|
+
code: string;
|
|
46
|
+
/** Must be the same redirect URI used in buildAuthorizeUrl. */
|
|
47
|
+
redirectUri: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExchangeCodeResult {
|
|
51
|
+
/** Long-lived credential — store in NEXUS_ADS_REFRESH_TOKEN (never in git). */
|
|
52
|
+
refreshToken: string;
|
|
53
|
+
accessToken: string;
|
|
54
|
+
expiresIn: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Exchange an authorization code for tokens (authorization_code grant).
|
|
59
|
+
* Returns the refresh token to persist server-side. Transport is injectable
|
|
60
|
+
* so tests run without live Google credentials.
|
|
61
|
+
*/
|
|
62
|
+
export async function exchangeCode(
|
|
63
|
+
input: ExchangeCodeInput,
|
|
64
|
+
transport: HttpTransport = fetchTransport,
|
|
65
|
+
): Promise<ExchangeCodeResult> {
|
|
66
|
+
if (!input.clientId || !input.clientSecret || !input.code || !input.redirectUri) {
|
|
67
|
+
throw new Error('[nexus-ads] exchangeCode: clientId, clientSecret, code, redirectUri all required');
|
|
68
|
+
}
|
|
69
|
+
const body = new URLSearchParams({
|
|
70
|
+
client_id: input.clientId,
|
|
71
|
+
client_secret: input.clientSecret,
|
|
72
|
+
code: input.code,
|
|
73
|
+
grant_type: 'authorization_code',
|
|
74
|
+
redirect_uri: input.redirectUri,
|
|
75
|
+
}).toString();
|
|
76
|
+
const res = await transport({
|
|
77
|
+
method: 'POST',
|
|
78
|
+
url: TOKEN_URL,
|
|
79
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
80
|
+
body,
|
|
81
|
+
});
|
|
82
|
+
let parsed: any;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(res.body);
|
|
85
|
+
} catch {
|
|
86
|
+
parsed = undefined;
|
|
87
|
+
}
|
|
88
|
+
if (res.status !== 200 || !parsed?.access_token) {
|
|
89
|
+
throw new Error(`[nexus-ads] code exchange failed: HTTP ${res.status} ${res.body}`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
refreshToken: parsed.refresh_token ?? '',
|
|
93
|
+
accessToken: parsed.access_token,
|
|
94
|
+
expiresIn: parsed.expires_in ?? 3600,
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -12,30 +12,65 @@ export interface HttpResponse {
|
|
|
12
12
|
}
|
|
13
13
|
export type HttpTransport = (req: HttpRequest) => Promise<HttpResponse>;
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
export interface
|
|
15
|
+
/** AdSense config as it appears in nexus.config.ts `ads`. */
|
|
16
|
+
export interface AdSenseConfig {
|
|
17
17
|
enabled: boolean;
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
/** OAuth2 client credentials. */
|
|
18
|
+
/** Publisher ID, `pub-xxx` or `ca-pub-xxx`. Used for display snippets. */
|
|
19
|
+
publisherId: string;
|
|
20
|
+
/** OAuth2 client credentials (AdSense Management API). */
|
|
21
21
|
clientId: string;
|
|
22
22
|
clientSecret: string;
|
|
23
|
-
/** OAuth2 refresh token (long-lived). */
|
|
23
|
+
/** OAuth2 refresh token (long-lived). Needs `adsense.readonly` scope. */
|
|
24
24
|
refreshToken: string;
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
/** Default customer ID to query. */
|
|
28
|
-
customerId: string;
|
|
25
|
+
/** Default AdSense account, `pub-xxx` or `accounts/pub-xxx`. Falls back to publisherId. */
|
|
26
|
+
accountId?: string;
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
export interface
|
|
33
|
-
id: string;
|
|
29
|
+
/** AdSense account resource (subset). */
|
|
30
|
+
export interface AdSenseAccount {
|
|
34
31
|
name: string;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
displayName?: string;
|
|
33
|
+
createTime?: string;
|
|
34
|
+
timeZone?: { id?: string };
|
|
38
35
|
}
|
|
39
36
|
|
|
40
|
-
/**
|
|
41
|
-
export
|
|
37
|
+
/** Ad client resource (subset). */
|
|
38
|
+
export interface AdClient {
|
|
39
|
+
name: string;
|
|
40
|
+
reportingDimensionId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Verified site resource (subset). */
|
|
44
|
+
export interface AdSenseSite {
|
|
45
|
+
name: string;
|
|
46
|
+
domain?: string;
|
|
47
|
+
state?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Ad unit resource (subset). */
|
|
51
|
+
export interface AdUnit {
|
|
52
|
+
name: string;
|
|
53
|
+
displayName?: string;
|
|
54
|
+
state?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Options for `reports:generate`. Dates are `YYYY-MM-DD`. */
|
|
58
|
+
export interface ReportOptions {
|
|
59
|
+
dimensions?: string[];
|
|
60
|
+
metrics?: string[];
|
|
61
|
+
startDate: string;
|
|
62
|
+
endDate: string;
|
|
63
|
+
filters?: string[];
|
|
64
|
+
orderBy?: string[];
|
|
65
|
+
limit?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A single report row: dimension/metric code → value. */
|
|
69
|
+
export type ReportRow = Record<string, string>;
|
|
70
|
+
|
|
71
|
+
/** Parsed report response. */
|
|
72
|
+
export interface ReportResult {
|
|
73
|
+
rows: ReportRow[];
|
|
74
|
+
totals?: ReportRow;
|
|
75
|
+
headers?: Array<{ name: string; type?: string }>;
|
|
76
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ReportBuilder,
|
|
4
|
+
AdSenseClient,
|
|
5
|
+
createAdSense,
|
|
6
|
+
earningsReportOptions,
|
|
7
|
+
topPagesReportOptions,
|
|
8
|
+
adsenseScript,
|
|
9
|
+
adUnit,
|
|
10
|
+
normalizeAdClient,
|
|
11
|
+
toAccountName,
|
|
12
|
+
type HttpTransport,
|
|
13
|
+
type HttpRequest,
|
|
14
|
+
type HttpResponse,
|
|
15
|
+
} from '../src/index.js';
|
|
16
|
+
|
|
17
|
+
describe('ReportBuilder', () => {
|
|
18
|
+
it('builds report options with dimensions/metrics/range/order/limit', () => {
|
|
19
|
+
const opts = new ReportBuilder()
|
|
20
|
+
.dimension('DATE')
|
|
21
|
+
.metric('PAGE_VIEWS', 'ESTIMATED_EARNINGS')
|
|
22
|
+
.range('2026-01-01', '2026-01-31')
|
|
23
|
+
.filter('COUNTRY_NAME==India')
|
|
24
|
+
.orderBy('+DATE')
|
|
25
|
+
.limit(10)
|
|
26
|
+
.build();
|
|
27
|
+
expect(opts).toEqual({
|
|
28
|
+
dimensions: ['DATE'],
|
|
29
|
+
metrics: ['PAGE_VIEWS', 'ESTIMATED_EARNINGS'],
|
|
30
|
+
startDate: '2026-01-01',
|
|
31
|
+
endDate: '2026-01-31',
|
|
32
|
+
filters: ['COUNTRY_NAME==India'],
|
|
33
|
+
orderBy: ['+DATE'],
|
|
34
|
+
limit: 10,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it('requires a date range and at least one metric', () => {
|
|
38
|
+
expect(() => new ReportBuilder().metric('PAGE_VIEWS').build()).toThrow(/range/);
|
|
39
|
+
expect(() => new ReportBuilder().range('2026-01-01', '2026-01-31').build()).toThrow(/metric/);
|
|
40
|
+
});
|
|
41
|
+
it('earningsReportOptions has daily earnings shape', () => {
|
|
42
|
+
const opts = earningsReportOptions('2026-01-01', '2026-01-31', 5);
|
|
43
|
+
expect(opts.dimensions).toEqual(['DATE']);
|
|
44
|
+
expect(opts.metrics).toContain('ESTIMATED_EARNINGS');
|
|
45
|
+
expect(opts.limit).toBe(5);
|
|
46
|
+
});
|
|
47
|
+
it('topPagesReportOptions orders by earnings desc', () => {
|
|
48
|
+
const opts = topPagesReportOptions('2026-01-01', '2026-01-31');
|
|
49
|
+
expect(opts.dimensions).toEqual(['PAGE_URL']);
|
|
50
|
+
expect(opts.orderBy).toEqual(['-ESTIMATED_EARNINGS']);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function mockTransport(routes: { match: string; respond: (req: HttpRequest) => HttpResponse }[]): HttpTransport {
|
|
55
|
+
return async (req) => {
|
|
56
|
+
for (const r of routes) if (req.url.includes(r.match)) return r.respond(req);
|
|
57
|
+
return { status: 404, body: JSON.stringify({ error: `no mock for ${req.url}` }) };
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cfg = {
|
|
62
|
+
enabled: true,
|
|
63
|
+
publisherId: 'pub-123',
|
|
64
|
+
clientId: 'cid',
|
|
65
|
+
clientSecret: 'csec',
|
|
66
|
+
refreshToken: 'rtok',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
describe('AdSenseClient', () => {
|
|
70
|
+
it('throws if required creds are missing', () => {
|
|
71
|
+
expect(() => new AdSenseClient({ ...cfg, clientId: '' } as any, mockTransport([]))).toThrow(/clientId/);
|
|
72
|
+
expect(() => new AdSenseClient({ ...cfg, publisherId: '', accountId: undefined } as any, mockTransport([]))).toThrow(
|
|
73
|
+
/publisherId/,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('normalizes account names', () => {
|
|
78
|
+
expect(toAccountName('pub-123')).toBe('accounts/pub-123');
|
|
79
|
+
expect(toAccountName('ca-pub-123')).toBe('accounts/pub-123');
|
|
80
|
+
expect(toAccountName('accounts/pub-123')).toBe('accounts/pub-123');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('acquires an OAuth2 token (cached) and lists accounts', async () => {
|
|
84
|
+
let tokenCalls = 0;
|
|
85
|
+
const t = mockTransport([
|
|
86
|
+
{
|
|
87
|
+
match: 'oauth2.googleapis.com/token',
|
|
88
|
+
respond: () => {
|
|
89
|
+
tokenCalls++;
|
|
90
|
+
return { status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
match: '/v2/accounts',
|
|
95
|
+
respond: (req) => {
|
|
96
|
+
expect(req.headers?.authorization).toBe('Bearer tok');
|
|
97
|
+
return { status: 200, body: JSON.stringify({ accounts: [{ name: 'accounts/pub-123' }] }) };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
const client = new AdSenseClient(cfg as any, t);
|
|
102
|
+
const accounts = await client.listAccounts();
|
|
103
|
+
expect(accounts).toHaveLength(1);
|
|
104
|
+
expect(accounts[0]!.name).toBe('accounts/pub-123');
|
|
105
|
+
// second call reuses the cached token
|
|
106
|
+
await client.listAccounts();
|
|
107
|
+
expect(tokenCalls).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('sends only Bearer auth (no developer-token) and paginates', async () => {
|
|
111
|
+
let calls = 0;
|
|
112
|
+
let sentHeaders: Record<string, string> = {};
|
|
113
|
+
const t = mockTransport([
|
|
114
|
+
{
|
|
115
|
+
match: 'oauth2.googleapis.com/token',
|
|
116
|
+
respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
match: '/adclients',
|
|
120
|
+
respond: (req) => {
|
|
121
|
+
calls++;
|
|
122
|
+
sentHeaders = req.headers ?? {};
|
|
123
|
+
if (calls === 1)
|
|
124
|
+
return { status: 200, body: JSON.stringify({ adClients: [{ name: 'a/1' }], nextPageToken: 'p2' }) };
|
|
125
|
+
return { status: 200, body: JSON.stringify({ adClients: [{ name: 'a/2' }] }) };
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
const client = new AdSenseClient(cfg as any, t);
|
|
130
|
+
const clients = await client.listAdClients();
|
|
131
|
+
expect(calls).toBe(2);
|
|
132
|
+
expect(clients).toHaveLength(2);
|
|
133
|
+
expect(sentHeaders.authorization).toBe('Bearer tok');
|
|
134
|
+
expect(sentHeaders['developer-token']).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('generateReport passes dimensions/metrics/dates and parses rows', async () => {
|
|
138
|
+
let sentUrl = '';
|
|
139
|
+
const t = mockTransport([
|
|
140
|
+
{
|
|
141
|
+
match: 'oauth2.googleapis.com/token',
|
|
142
|
+
respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
match: 'reports:generate',
|
|
146
|
+
respond: (req) => {
|
|
147
|
+
sentUrl = req.url;
|
|
148
|
+
return {
|
|
149
|
+
status: 200,
|
|
150
|
+
body: JSON.stringify({ rows: [{ DATE: '2026-01-01', ESTIMATED_EARNINGS: '1.5' }], totals: { ESTIMATED_EARNINGS: '1.5' } }),
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
]);
|
|
155
|
+
const client = new AdSenseClient(cfg as any, t);
|
|
156
|
+
const report = await client.generateReport({
|
|
157
|
+
dimensions: ['DATE'],
|
|
158
|
+
metrics: ['ESTIMATED_EARNINGS'],
|
|
159
|
+
startDate: '2026-01-01',
|
|
160
|
+
endDate: '2026-01-31',
|
|
161
|
+
});
|
|
162
|
+
expect(sentUrl).toContain('reports:generate');
|
|
163
|
+
expect(sentUrl).toContain('startDate=2026-01-01');
|
|
164
|
+
expect(report.rows).toHaveLength(1);
|
|
165
|
+
expect(report.totals?.ESTIMATED_EARNINGS).toBe('1.5');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('propagates API errors', async () => {
|
|
169
|
+
const t = mockTransport([
|
|
170
|
+
{
|
|
171
|
+
match: 'oauth2.googleapis.com/token',
|
|
172
|
+
respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }),
|
|
173
|
+
},
|
|
174
|
+
{ match: '/v2/', respond: () => ({ status: 403, body: JSON.stringify({ error: { message: 'denied' } }) }) },
|
|
175
|
+
]);
|
|
176
|
+
const client = new AdSenseClient(cfg as any, t);
|
|
177
|
+
await expect(client.listSites()).rejects.toThrow(/failed/);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('createAdSense factory', () => {
|
|
182
|
+
it('builds a client with the default transport', () => {
|
|
183
|
+
const client = createAdSense(cfg as any);
|
|
184
|
+
expect(client).toBeInstanceOf(AdSenseClient);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('adsenseHtml', () => {
|
|
189
|
+
it('normalizes publisher ids to ca-pub-', () => {
|
|
190
|
+
expect(normalizeAdClient('pub-123')).toBe('ca-pub-123');
|
|
191
|
+
expect(normalizeAdClient('ca-pub-123')).toBe('ca-pub-123');
|
|
192
|
+
});
|
|
193
|
+
it('renders the loader script with the client id', () => {
|
|
194
|
+
const html = adsenseScript('pub-123');
|
|
195
|
+
expect(html).toContain('pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-123');
|
|
196
|
+
expect(html).toContain('async');
|
|
197
|
+
});
|
|
198
|
+
it('renders an ad unit with escaped attrs + push snippet', () => {
|
|
199
|
+
const html = adUnit({ adClient: 'pub-123', adSlot: '456', format: 'auto' });
|
|
200
|
+
expect(html).toContain('data-ad-client="ca-pub-123"');
|
|
201
|
+
expect(html).toContain('data-ad-slot="456"');
|
|
202
|
+
expect(html).toContain('(adsbygoogle = window.adsbygoogle || []).push({});');
|
|
203
|
+
});
|
|
204
|
+
it('escapes quotes in attrs', () => {
|
|
205
|
+
const html = adUnit({ adClient: 'pub-123', adSlot: '4" onload="x' });
|
|
206
|
+
expect(html).not.toContain('onload="x');
|
|
207
|
+
expect(html).toContain('"');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
buildAuthorizeUrl,
|
|
4
|
+
exchangeCode,
|
|
5
|
+
ADSENSE_READONLY_SCOPE,
|
|
6
|
+
type HttpTransport,
|
|
7
|
+
} from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
describe('buildAuthorizeUrl', () => {
|
|
10
|
+
it('includes client, redirect, offline access and the adsense scope', () => {
|
|
11
|
+
const url = buildAuthorizeUrl({
|
|
12
|
+
clientId: 'cid.apps.googleusercontent.com',
|
|
13
|
+
redirectUri: 'https://nexus.bhooai.com',
|
|
14
|
+
state: 's1',
|
|
15
|
+
});
|
|
16
|
+
const parsed = new URL(url);
|
|
17
|
+
expect(parsed.origin + parsed.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
|
|
18
|
+
expect(parsed.searchParams.get('client_id')).toBe('cid.apps.googleusercontent.com');
|
|
19
|
+
expect(parsed.searchParams.get('redirect_uri')).toBe('https://nexus.bhooai.com');
|
|
20
|
+
expect(parsed.searchParams.get('response_type')).toBe('code');
|
|
21
|
+
expect(parsed.searchParams.get('access_type')).toBe('offline');
|
|
22
|
+
expect(parsed.searchParams.get('prompt')).toBe('consent');
|
|
23
|
+
expect(parsed.searchParams.get('scope')).toContain(ADSENSE_READONLY_SCOPE);
|
|
24
|
+
expect(parsed.searchParams.get('state')).toBe('s1');
|
|
25
|
+
});
|
|
26
|
+
it('requires clientId and redirectUri', () => {
|
|
27
|
+
expect(() => buildAuthorizeUrl({ clientId: '', redirectUri: 'https://x' })).toThrow(/clientId/);
|
|
28
|
+
expect(() => buildAuthorizeUrl({ clientId: 'c', redirectUri: '' })).toThrow(/redirectUri/);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('exchangeCode', () => {
|
|
33
|
+
const okTransport: HttpTransport = async (req) => {
|
|
34
|
+
expect(req.url).toContain('oauth2.googleapis.com/token');
|
|
35
|
+
const params = new URLSearchParams(req.body);
|
|
36
|
+
expect(params.get('grant_type')).toBe('authorization_code');
|
|
37
|
+
expect(params.get('code')).toBe('authcode123');
|
|
38
|
+
return {
|
|
39
|
+
status: 200,
|
|
40
|
+
body: JSON.stringify({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
it('returns the refresh token to persist', async () => {
|
|
44
|
+
const out = await exchangeCode(
|
|
45
|
+
{ clientId: 'c', clientSecret: 's', code: 'authcode123', redirectUri: 'https://nexus.bhooai.com' },
|
|
46
|
+
okTransport,
|
|
47
|
+
);
|
|
48
|
+
expect(out).toEqual({ refreshToken: 'rt', accessToken: 'at', expiresIn: 3600 });
|
|
49
|
+
});
|
|
50
|
+
it('throws on non-200 without leaking transport internals', async () => {
|
|
51
|
+
const bad: HttpTransport = async () => ({ status: 400, body: JSON.stringify({ error: 'invalid_grant' }) });
|
|
52
|
+
await expect(
|
|
53
|
+
exchangeCode({ clientId: 'c', clientSecret: 's', code: 'bad', redirectUri: 'https://x' }, bad),
|
|
54
|
+
).rejects.toThrow(/code exchange failed: HTTP 400/);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/vitest.config.ts
CHANGED
|
@@ -4,7 +4,7 @@ export default defineProject({
|
|
|
4
4
|
test: {
|
|
5
5
|
environment: 'node',
|
|
6
6
|
include: ['tests/**/*.test.ts'],
|
|
7
|
-
env: { NEXUS_LICENSE_KEY: 'nxl_test_master_key_001' },
|
|
7
|
+
env: { NEXUS_LICENSE_KEY: 'nxl_test_master_key_001', NEXUS_LICENSE_ISSUER: '1' },
|
|
8
8
|
globals: false,
|
|
9
9
|
testTimeout: 15_000,
|
|
10
10
|
},
|
package/src/GaqlBuilder.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GAQL (Google Ads Query Language) builder. Produces queries like:
|
|
3
|
-
* SELECT campaign.id, campaign.name FROM campaign WHERE campaign.status = 'ENABLED' ORDER BY campaign.id LIMIT 50
|
|
4
|
-
*/
|
|
5
|
-
export class GaqlBuilder {
|
|
6
|
-
private fields: string[] = [];
|
|
7
|
-
private resource: string = '';
|
|
8
|
-
private conditions: string[] = [];
|
|
9
|
-
private order?: { field: string; dir: 'ASC' | 'DESC' };
|
|
10
|
-
private limitN?: number;
|
|
11
|
-
|
|
12
|
-
select(...fields: string[]): this {
|
|
13
|
-
this.fields.push(...fields);
|
|
14
|
-
return this;
|
|
15
|
-
}
|
|
16
|
-
from(resource: string): this {
|
|
17
|
-
this.resource = resource;
|
|
18
|
-
return this;
|
|
19
|
-
}
|
|
20
|
-
/** Add a condition, e.g. `where("campaign.status = 'ENABLED'")`. */
|
|
21
|
-
where(condition: string): this {
|
|
22
|
-
this.conditions.push(condition);
|
|
23
|
-
return this;
|
|
24
|
-
}
|
|
25
|
-
orderBy(field: string, dir: 'ASC' | 'DESC' = 'ASC'): this {
|
|
26
|
-
this.order = { field, dir };
|
|
27
|
-
return this;
|
|
28
|
-
}
|
|
29
|
-
limit(n: number): this {
|
|
30
|
-
this.limitN = n;
|
|
31
|
-
return this;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
build(): string {
|
|
35
|
-
if (!this.resource) throw new Error('[nexus-ads] GaqlBuilder: FROM resource required');
|
|
36
|
-
if (this.fields.length === 0) throw new Error('[nexus-ads] GaqlBuilder: at least one field required');
|
|
37
|
-
let q = `SELECT ${this.fields.join(', ')} FROM ${this.resource}`;
|
|
38
|
-
if (this.conditions.length) q += ` WHERE ${this.conditions.join(' AND ')}`;
|
|
39
|
-
if (this.order) q += ` ORDER BY ${this.order.field} ${this.order.dir}`;
|
|
40
|
-
if (this.limitN != null) q += ` LIMIT ${this.limitN}`;
|
|
41
|
-
return q;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Convenience: standard campaign listing query. */
|
|
46
|
-
export function campaignsQuery(limit = 50): string {
|
|
47
|
-
return new GaqlBuilder()
|
|
48
|
-
.select('campaign.id', 'campaign.name', 'campaign.status', 'campaign.advertising_channel_type')
|
|
49
|
-
.from('campaign')
|
|
50
|
-
.orderBy('campaign.id')
|
|
51
|
-
.limit(limit)
|
|
52
|
-
.build();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Convenience: campaign performance metrics for a date range (YYYY-MM-DD). */
|
|
56
|
-
export function campaignMetricsQuery(fromDate: string, toDate: string, limit = 50): string {
|
|
57
|
-
return new GaqlBuilder()
|
|
58
|
-
.select(
|
|
59
|
-
'campaign.id',
|
|
60
|
-
'campaign.name',
|
|
61
|
-
'metrics.impressions',
|
|
62
|
-
'metrics.clicks',
|
|
63
|
-
'metrics.cost_micros',
|
|
64
|
-
'metrics.conversions',
|
|
65
|
-
)
|
|
66
|
-
.from('campaign')
|
|
67
|
-
.where(`segments.date >= '${fromDate}'`)
|
|
68
|
-
.where(`segments.date <= '${toDate}'`)
|
|
69
|
-
.orderBy('campaign.id')
|
|
70
|
-
.limit(limit)
|
|
71
|
-
.build();
|
|
72
|
-
}
|
package/src/GoogleAdsClient.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import type { GoogleAdsConfig, HttpTransport, AdsRow, Campaign } from './types.js';
|
|
2
|
-
|
|
3
|
-
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Google Ads REST client (no grpc/native deps). Uses the Google Ads API REST
|
|
7
|
-
* endpoint `googleAds:search` with GAQL. OAuth2 access tokens are acquired via
|
|
8
|
-
* the refresh-token grant and cached until near expiry. The HTTP transport is
|
|
9
|
-
* injectable so tests run without live credentials.
|
|
10
|
-
*/
|
|
11
|
-
export class GoogleAdsClient {
|
|
12
|
-
private readonly cfg: GoogleAdsConfig;
|
|
13
|
-
private readonly transport: HttpTransport;
|
|
14
|
-
private readonly apiVersion: string;
|
|
15
|
-
private token: { value: string; expiresAt: number } | null = null;
|
|
16
|
-
|
|
17
|
-
constructor(cfg: GoogleAdsConfig, transport: HttpTransport, apiVersion = 'v17') {
|
|
18
|
-
this.cfg = cfg;
|
|
19
|
-
this.transport = transport;
|
|
20
|
-
this.apiVersion = apiVersion;
|
|
21
|
-
if (!cfg.developerToken || !cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
|
|
22
|
-
throw new Error('[nexus-ads] developerToken, clientId, clientSecret, refreshToken all required');
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
private baseUrl(): string {
|
|
27
|
-
return `https://googleads.googleapis.com/${this.apiVersion}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** OAuth2 refresh-token → access token, cached with a 60s safety margin. */
|
|
31
|
-
private async accessToken(): Promise<string> {
|
|
32
|
-
if (this.token && Date.now() < this.token.expiresAt - 60_000) return this.token.value;
|
|
33
|
-
const body = new URLSearchParams({
|
|
34
|
-
client_id: this.cfg.clientId,
|
|
35
|
-
client_secret: this.cfg.clientSecret,
|
|
36
|
-
refresh_token: this.cfg.refreshToken,
|
|
37
|
-
grant_type: 'refresh_token',
|
|
38
|
-
}).toString();
|
|
39
|
-
const res = await this.transport({
|
|
40
|
-
method: 'POST',
|
|
41
|
-
url: TOKEN_URL,
|
|
42
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
43
|
-
body,
|
|
44
|
-
});
|
|
45
|
-
const parsed = safeJson(res.body);
|
|
46
|
-
if (res.status !== 200 || !parsed?.access_token) {
|
|
47
|
-
throw new Error(`[nexus-ads] token refresh failed: HTTP ${res.status} ${res.body}`);
|
|
48
|
-
}
|
|
49
|
-
this.token = { value: parsed.access_token, expiresAt: Date.now() + (parsed.expires_in ?? 3600) * 1000 };
|
|
50
|
-
return this.token.value;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
private async searchHeaders(): Promise<Record<string, string>> {
|
|
54
|
-
return {
|
|
55
|
-
authorization: `Bearer ${await this.accessToken()}`,
|
|
56
|
-
'developer-token': this.cfg.developerToken,
|
|
57
|
-
'content-type': 'application/json',
|
|
58
|
-
...(this.cfg.loginCustomerId ? { 'login-customer-id': normalizeId(this.cfg.loginCustomerId) } : {}),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Run a GAQL query against a customer and return all rows (handles pagination). */
|
|
63
|
-
async search(customerId: string, query: string): Promise<AdsRow[]> {
|
|
64
|
-
const headers = await this.searchHeaders();
|
|
65
|
-
let pageToken: string | undefined;
|
|
66
|
-
const rows: AdsRow[] = [];
|
|
67
|
-
do {
|
|
68
|
-
const body = JSON.stringify({ query, ...(pageToken ? { pageToken } : {}) });
|
|
69
|
-
const res = await this.transport({
|
|
70
|
-
method: 'POST',
|
|
71
|
-
url: `${this.baseUrl()}/customers/${normalizeId(customerId)}/googleAds:search`,
|
|
72
|
-
headers,
|
|
73
|
-
body,
|
|
74
|
-
});
|
|
75
|
-
const parsed = safeJson(res.body);
|
|
76
|
-
if (res.status !== 200) {
|
|
77
|
-
throw new Error(`[nexus-ads] search failed: HTTP ${res.status} ${res.body}`);
|
|
78
|
-
}
|
|
79
|
-
if (Array.isArray(parsed?.results)) rows.push(...parsed.results);
|
|
80
|
-
pageToken = parsed?.nextPageToken;
|
|
81
|
-
} while (pageToken);
|
|
82
|
-
return rows;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** List campaigns on the default customer. */
|
|
86
|
-
async listCampaigns(customerId = this.cfg.customerId, limit = 50): Promise<Campaign[]> {
|
|
87
|
-
const rows = await this.search(customerId, listCampaignsGaql(limit));
|
|
88
|
-
return rows.map(rowToCampaign);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Campaign metrics for a date range (YYYY-MM-DD). */
|
|
92
|
-
async campaignMetrics(fromDate: string, toDate: string, customerId = this.cfg.customerId, limit = 50): Promise<AdsRow[]> {
|
|
93
|
-
const rows = await this.search(customerId, metricsGaql(fromDate, toDate, limit));
|
|
94
|
-
return rows;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function listCampaignsGaql(limit: number): string {
|
|
99
|
-
return `SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type FROM campaign ORDER BY campaign.id LIMIT ${limit}`;
|
|
100
|
-
}
|
|
101
|
-
function metricsGaql(from: string, to: string, limit: number): string {
|
|
102
|
-
return `SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date >= '${from}' AND segments.date <= '${to}' ORDER BY campaign.id LIMIT ${limit}`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function rowToCampaign(row: AdsRow): Campaign {
|
|
106
|
-
const c = (row.campaign ?? {}) as Record<string, unknown>;
|
|
107
|
-
return {
|
|
108
|
-
id: String(c.id ?? ''),
|
|
109
|
-
name: String(c.name ?? ''),
|
|
110
|
-
status: String(c.status ?? ''),
|
|
111
|
-
advertisingChannelType: c.advertisingChannelType != null ? String(c.advertisingChannelType) : undefined,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function normalizeId(id: string): string {
|
|
116
|
-
return id.replace(/-/g, '');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function safeJson(body: string): any {
|
|
120
|
-
try { return JSON.parse(body); } catch { return undefined; }
|
|
121
|
-
}
|
package/tests/ads.test.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { GaqlBuilder, GoogleAdsClient, createGoogleAds, campaignsQuery, campaignMetricsQuery, type HttpTransport, type HttpRequest, type HttpResponse } from '../src/index.js';
|
|
3
|
-
|
|
4
|
-
describe('GaqlBuilder', () => {
|
|
5
|
-
it('builds a SELECT/FROM/WHERE/ORDER/LIMIT query', () => {
|
|
6
|
-
const q = new GaqlBuilder().select('campaign.id', 'campaign.name').from('campaign').where("campaign.status = 'ENABLED'").orderBy('campaign.id', 'DESC').limit(10).build();
|
|
7
|
-
expect(q).toBe("SELECT campaign.id, campaign.name FROM campaign WHERE campaign.status = 'ENABLED' ORDER BY campaign.id DESC LIMIT 10");
|
|
8
|
-
});
|
|
9
|
-
it('joins multiple WHERE with AND', () => {
|
|
10
|
-
const q = new GaqlBuilder().select('campaign.id').from('campaign').where('a = 1').where('b = 2').build();
|
|
11
|
-
expect(q).toBe('SELECT campaign.id FROM campaign WHERE a = 1 AND b = 2');
|
|
12
|
-
});
|
|
13
|
-
it('requires FROM and at least one field', () => {
|
|
14
|
-
expect(() => new GaqlBuilder().select('x').build()).toThrow(/FROM/);
|
|
15
|
-
expect(() => new GaqlBuilder().from('campaign').build()).toThrow(/field/);
|
|
16
|
-
});
|
|
17
|
-
it('campaignsQuery convenience builds the standard query', () => {
|
|
18
|
-
expect(campaignsQuery(5)).toBe('SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type FROM campaign ORDER BY campaign.id ASC LIMIT 5');
|
|
19
|
-
});
|
|
20
|
-
it('campaignMetricsQuery includes date range + metrics', () => {
|
|
21
|
-
const q = campaignMetricsQuery('2024-01-01', '2024-01-31', 10);
|
|
22
|
-
expect(q).toContain("segments.date >= '2024-01-01'");
|
|
23
|
-
expect(q).toContain("segments.date <= '2024-01-31'");
|
|
24
|
-
expect(q).toContain('metrics.clicks');
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
function mockTransport(routes: { match: string; respond: (req: HttpRequest) => HttpResponse }[]): HttpTransport {
|
|
29
|
-
return async (req) => {
|
|
30
|
-
for (const r of routes) if (req.url.includes(r.match)) return r.respond(req);
|
|
31
|
-
return { status: 404, body: JSON.stringify({ error: `no mock for ${req.url}` }) };
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const cfg = {
|
|
36
|
-
enabled: true,
|
|
37
|
-
developerToken: 'dev-token',
|
|
38
|
-
clientId: 'cid',
|
|
39
|
-
clientSecret: 'csec',
|
|
40
|
-
refreshToken: 'rtok',
|
|
41
|
-
customerId: '123-456-7890',
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
describe('GoogleAdsClient', () => {
|
|
45
|
-
it('throws if required creds are missing', () => {
|
|
46
|
-
expect(() => new GoogleAdsClient({ ...cfg, developerToken: '' } as any, mockTransport([]))).toThrow(/developerToken/);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('acquires an OAuth2 token (cached) and calls googleAds:search', async () => {
|
|
50
|
-
let tokenCalls = 0;
|
|
51
|
-
const t = mockTransport([
|
|
52
|
-
{ match: 'oauth2.googleapis.com/token', respond: (req) => { tokenCalls++; void req; return { status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }; } },
|
|
53
|
-
{ match: 'googleAds:search', respond: (req) => ({ status: 200, body: JSON.stringify({ results: [{ campaign: { id: '1', name: 'Camp A', status: 'ENABLED', advertisingChannelType: 'SEARCH' } }] }) }) },
|
|
54
|
-
]);
|
|
55
|
-
const client = new GoogleAdsClient(cfg as any, t);
|
|
56
|
-
const rows = await client.search('1234567890', 'SELECT campaign.id FROM campaign LIMIT 1');
|
|
57
|
-
expect(tokenCalls).toBe(1);
|
|
58
|
-
expect(rows).toHaveLength(1);
|
|
59
|
-
expect((rows[0]!.campaign as any).name).toBe('Camp A');
|
|
60
|
-
// second call reuses the cached token
|
|
61
|
-
await client.search('1234567890', 'SELECT campaign.id FROM campaign LIMIT 1');
|
|
62
|
-
expect(tokenCalls).toBe(1);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('listCampaigns maps rows to Campaign objects', async () => {
|
|
66
|
-
const t = mockTransport([
|
|
67
|
-
{ match: 'oauth2.googleapis.com/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
|
|
68
|
-
{ match: 'googleAds:search', respond: (req) => {
|
|
69
|
-
const body = JSON.parse(req.body as string);
|
|
70
|
-
expect(body.query).toContain('FROM campaign');
|
|
71
|
-
return { status: 200, body: JSON.stringify({ results: [{ campaign: { id: '1', name: 'A', status: 'ENABLED' } }, { campaign: { id: '2', name: 'B', status: 'PAUSED' } }] }) };
|
|
72
|
-
} },
|
|
73
|
-
]);
|
|
74
|
-
const client = new GoogleAdsClient(cfg as any, t);
|
|
75
|
-
const camps = await client.listCampaigns();
|
|
76
|
-
expect(camps).toHaveLength(2);
|
|
77
|
-
expect(camps[0]!.name).toBe('A');
|
|
78
|
-
expect(camps[1]!.id).toBe('2');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('normalizes customer ids (strips hyphens) and sends developer-token header', async () => {
|
|
82
|
-
let sentHeaders: Record<string, string> = {};
|
|
83
|
-
let sentUrl = '';
|
|
84
|
-
const t = mockTransport([
|
|
85
|
-
{ match: 'oauth2.googleapis.com/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
|
|
86
|
-
{ match: 'googleAds:search', respond: (req) => { sentHeaders = req.headers ?? {}; sentUrl = req.url; return { status: 200, body: JSON.stringify({ results: [] }) }; } },
|
|
87
|
-
]);
|
|
88
|
-
const client = new GoogleAdsClient({ ...cfg, loginCustomerId: '11-22-33' } as any, t);
|
|
89
|
-
await client.search('1-2-3', 'SELECT campaign.id FROM campaign');
|
|
90
|
-
expect(sentUrl).toContain('/customers/123/googleAds:search');
|
|
91
|
-
expect(sentHeaders['developer-token']).toBe('dev-token');
|
|
92
|
-
expect(sentHeaders['login-customer-id']).toBe('112233');
|
|
93
|
-
expect(sentHeaders.authorization).toBe('Bearer tok');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('propagates search errors', async () => {
|
|
97
|
-
const t = mockTransport([
|
|
98
|
-
{ match: 'oauth2.googleapis.com/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
|
|
99
|
-
{ match: 'googleAds:search', respond: () => ({ status: 400, body: JSON.stringify({ error: { message: 'bad query' } }) }) },
|
|
100
|
-
]);
|
|
101
|
-
const client = new GoogleAdsClient(cfg as any, t);
|
|
102
|
-
await expect(client.search('123', 'bad')).rejects.toThrow(/search failed/);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('handles pagination via nextPageToken', async () => {
|
|
106
|
-
let call = 0;
|
|
107
|
-
const t = mockTransport([
|
|
108
|
-
{ match: 'oauth2.googleapis.com/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
|
|
109
|
-
{ match: 'googleAds:search', respond: () => {
|
|
110
|
-
call++;
|
|
111
|
-
if (call === 1) return { status: 200, body: JSON.stringify({ results: [{ campaign: { id: '1' } }], nextPageToken: 'tok2' }) };
|
|
112
|
-
return { status: 200, body: JSON.stringify({ results: [{ campaign: { id: '2' } }] }) };
|
|
113
|
-
} },
|
|
114
|
-
]);
|
|
115
|
-
const client = new GoogleAdsClient(cfg as any, t);
|
|
116
|
-
const rows = await client.search('123', 'SELECT campaign.id FROM campaign');
|
|
117
|
-
expect(call).toBe(2);
|
|
118
|
-
expect(rows).toHaveLength(2);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
describe('createGoogleAds factory', () => {
|
|
123
|
-
it('builds a client with the default transport', () => {
|
|
124
|
-
const client = createGoogleAds(cfg as any);
|
|
125
|
-
expect(client).toBeInstanceOf(GoogleAdsClient);
|
|
126
|
-
});
|
|
127
|
-
});
|