diff --git a/.env.example b/.env.example index 95678b1..ae03fdb 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,18 @@ -# Example environment variables for the Amazon Ads CLI -# Copy this file to .env and fill in your values +# Example environment variables for the Amazon Ads CLI. +# Copy this file to .env and fill in your values, or run `amazonads auth login` +# to save credentials to ~/.config/amazonads-cli/config.json instead. +# Environment variables override the config file. # Amazon Ads API credentials AMAZON_ADS_CLIENT_ID=your_client_id_here AMAZON_ADS_CLIENT_SECRET=your_client_secret_here AMAZON_ADS_REFRESH_TOKEN=your_refresh_token_here -# API endpoint (optional) -AMAZON_ADS_API_ENDPOINT=https://advertising-api.amazon.com +# Region: NA, EU, or FE (default NA) +AMAZON_ADS_REGION=NA + +# Use the Amazon Ads sandbox endpoint (default false) +AMAZON_ADS_SANDBOX=false + +# Default profile ID, so --profile is not needed on every command +AMAZON_ADS_PROFILE_ID= diff --git a/.gitignore b/.gitignore index 99da9e1..c967d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ .env *.js.map *.d.ts.map +.claude/worktrees/ diff --git a/README.md b/README.md index 704d4b4..7d36f07 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ npm install -g amazonads-cli amazonads auth login ``` -Prompts for your Client ID and Client Secret, opens a browser to complete Amazon OAuth, and saves credentials to `~/.config/amazonads-cli/config.json`. +Prompts for your Client ID, Client Secret, and region (NA, EU, or FE), opens the regional Login with Amazon consent page, and saves credentials to `~/.config/amazonads-cli/config.json`. You can also create the config file manually if you already have a refresh token: diff --git a/package-lock.json b/package-lock.json index 85d061e..77d13e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "2.0.0", "license": "MIT", "dependencies": { - "axios": "^1.7.7", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -983,18 +982,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1014,24 +1001,6 @@ "node": ">=12" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1057,19 +1026,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1124,18 +1080,6 @@ "@colors/colors": "1.5.0" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -1149,6 +1093,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1212,15 +1157,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1233,44 +1169,12 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1278,33 +1182,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -1367,42 +1244,6 @@ "node": ">=12.0.0" } }, - "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1418,52 +1259,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1477,70 +1272,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1615,40 +1346,11 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1757,15 +1459,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", diff --git a/package.json b/package.json index 7962515..fcae015 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,9 @@ "test": "vitest run", "test:watch": "vitest", "format": "prettier --write .", - "clean": "rm -rf dist coverage" + "clean": "rm -rf build coverage" }, "dependencies": { - "axios": "^1.7.7", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts new file mode 100644 index 0000000..04f6e85 --- /dev/null +++ b/src/__tests__/amazon-ads-client.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { gzipSync } from "zlib"; +import { AmazonAdsClient } from "../amazon-ads-client.js"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function tokenResponse(): Response { + return jsonResponse(200, { access_token: "access-123", expires_in: 3600 }); +} + +function makeClient(region = "NA"): AmazonAdsClient { + return new AmazonAdsClient({ + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + region, + sandbox: false, + }); +} + +describe("AmazonAdsClient token refresh", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("refreshes against the regional Login with Amazon host", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, [])); + + await makeClient("EU").getProfiles(); + + expect(fetchMock.mock.calls[0][0]).toBe("https://api.amazon.co.uk/auth/o2/token"); + expect(fetchMock.mock.calls[1][0]).toBe("https://advertising-api-eu.amazon.com/v2/profiles"); + }); +}); + +describe("AmazonAdsClient multi-status handling", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("throws when a 207 response reports a rejected item", async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce( + jsonResponse(207, { + keywords: { + success: [], + error: [ + { + index: 0, + errors: [ + { + errorType: "ENTITY_NOT_FOUND", + errorValue: { message: "Keyword 999 does not exist" }, + }, + ], + }, + ], + }, + }) + ); + + await expect(makeClient().updateKeywordBid("profile-1", "999", 1.25)).rejects.toThrow( + /ENTITY_NOT_FOUND/ + ); + }); + + it("resolves with the body when a 207 response has no rejected items", async () => { + const body = { + keywords: { success: [{ index: 0, keywordId: "123" }], error: [] }, + }; + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse(207, body)); + + await expect(makeClient().updateKeywordBid("profile-1", "123", 1.25)).resolves.toEqual(body); + }); + + it("throws when a campaign create is rejected", async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce( + jsonResponse(207, { + campaigns: { + success: [], + error: [ + { + index: 0, + errors: [{ errorType: "DUPLICATE_VALUE", errorValue: { message: "Name already used" } }], + }, + ], + }, + }) + ); + + await expect( + makeClient().createCampaign("profile-1", "Dup", "sponsoredProducts", "MANUAL", 10, "2026-10-13") + ).rejects.toThrow(/DUPLICATE_VALUE/); + }); + + it("leaves list responses untouched", async () => { + const body = { campaigns: [{ campaignId: "1" }], totalResults: 1 }; + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse(200, body)); + + await expect(makeClient().getCampaigns("profile-1")).resolves.toEqual(body); + }); +}); + +describe("AmazonAdsClient.getCampaignPerformance", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function gzipResponse(rows: unknown[]): Response { + return new Response(gzipSync(JSON.stringify(rows)), { status: 200 }); + } + + function mockCompletedReport(rows: unknown[]): void { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, { reportId: "r1" })) + .mockResolvedValueOnce( + jsonResponse(200, { status: "COMPLETED", url: "https://example.com/r1.gz" }) + ) + .mockResolvedValueOnce(gzipResponse(rows)); + } + + it("returns only the requested campaign's rows", async () => { + mockCompletedReport([ + { campaignId: 111, campaignName: "A" }, + { campaignId: 222, campaignName: "B" }, + ]); + + const rows = await makeClient().getCampaignPerformance("p", "222", "2026-10-01", "2026-10-10"); + + expect(rows).toEqual([{ campaignId: 222, campaignName: "B" }]); + }); + + it("returns an empty list, not every campaign, when the campaign has no rows", async () => { + mockCompletedReport([ + { campaignId: 111, campaignName: "A" }, + { campaignId: 222, campaignName: "B" }, + ]); + + const rows = await makeClient().getCampaignPerformance("p", "999", "2026-10-01", "2026-10-10"); + + expect(rows).toEqual([]); + }); +}); + +describe("AmazonAdsClient list pagination", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function requestBody(callIndex: number): Record { + return JSON.parse(fetchMock.mock.calls[callIndex][1].body as string); + } + + it("follows nextToken until every campaign page is fetched", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + jsonResponse(200, { campaigns: [{ campaignId: "1" }], totalResults: 2, nextToken: "page2" }) + ) + .mockResolvedValueOnce(jsonResponse(200, { campaigns: [{ campaignId: "2" }], totalResults: 2 })); + + const result = await makeClient().getCampaigns("p", "enabled"); + + expect(result).toEqual({ + campaigns: [{ campaignId: "1" }, { campaignId: "2" }], + totalResults: 2, + }); + expect(requestBody(1)).toEqual({ stateFilter: { include: ["ENABLED"] } }); + expect(requestBody(2)).toEqual({ stateFilter: { include: ["ENABLED"] }, nextToken: "page2" }); + }); + + it("makes a single request when the first page has no nextToken", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, { keywords: [{ keywordId: "k1" }], totalResults: 1 })); + + const result = await makeClient().getKeywords("p", "c1"); + + expect(result).toEqual({ keywords: [{ keywordId: "k1" }], totalResults: 1 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(requestBody(1)).toEqual({ campaignIdFilter: { include: ["c1"] } }); + }); + + it("paginates product ads and portfolios too", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, { productAds: [{ adId: "a1" }], nextToken: "n" })) + .mockResolvedValueOnce(jsonResponse(200, { productAds: [{ adId: "a2" }] })) + .mockResolvedValueOnce(jsonResponse(200, { portfolios: [{ portfolioId: 1 }], nextToken: "n" })) + .mockResolvedValueOnce(jsonResponse(200, { portfolios: [{ portfolioId: 2 }] })); + + const client = makeClient(); + const ads = await client.getProductAds("p"); + const portfolios = await client.getPortfolios("p"); + + expect(ads).toEqual({ productAds: [{ adId: "a1" }, { adId: "a2" }] }); + expect(portfolios).toEqual({ portfolios: [{ portfolioId: 1 }, { portfolioId: 2 }] }); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index c8186f3..fced9c7 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -70,6 +70,24 @@ describe("config", () => { expect(config.region).toBe("EU"); }); + it("saveConfig writes the file and its directory readable only by the owner", () => { + saveConfig({ clientSecret: "secret" }); + + const fileMode = fs.statSync(getConfigPath()).mode & 0o777; + const dirMode = fs.statSync(path.dirname(getConfigPath())).mode & 0o777; + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + }); + + it("saveConfig tightens permissions on an existing world-readable file", () => { + saveConfig({ clientSecret: "secret" }); + fs.chmodSync(getConfigPath(), 0o644); + + saveConfig({ clientSecret: "rotated" }); + + expect(fs.statSync(getConfigPath()).mode & 0o777).toBe(0o600); + }); + it("deleteConfig removes the config file", () => { saveConfig({ clientId: "test" }); expect(fs.existsSync(getConfigPath())).toBe(true); diff --git a/src/__tests__/lwa.test.ts b/src/__tests__/lwa.test.ts new file mode 100644 index 0000000..89e6784 --- /dev/null +++ b/src/__tests__/lwa.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { buildConsentUrl, getTokenUrl, normalizeRegion } from "../lwa.js"; + +describe("normalizeRegion", () => { + it("defaults to NA when empty", () => { + expect(normalizeRegion(undefined)).toBe("NA"); + expect(normalizeRegion("")).toBe("NA"); + expect(normalizeRegion(" ")).toBe("NA"); + }); + + it("accepts any case and surrounding whitespace", () => { + expect(normalizeRegion("eu")).toBe("EU"); + expect(normalizeRegion(" fe ")).toBe("FE"); + }); + + it("rejects unknown regions", () => { + expect(() => normalizeRegion("UK")).toThrow(/NA, EU, FE/); + }); +}); + +describe("getTokenUrl", () => { + it("returns the regional Login with Amazon token host", () => { + expect(getTokenUrl("NA")).toBe("https://api.amazon.com/auth/o2/token"); + expect(getTokenUrl("EU")).toBe("https://api.amazon.co.uk/auth/o2/token"); + expect(getTokenUrl("FE")).toBe("https://api.amazon.co.jp/auth/o2/token"); + }); +}); + +describe("buildConsentUrl", () => { + it("uses the regional consent host", () => { + expect(buildConsentUrl("NA", "cid", "http://localhost:3000/callback").origin).toBe( + "https://www.amazon.com" + ); + expect(buildConsentUrl("EU", "cid", "http://localhost:3000/callback").origin).toBe( + "https://eu.account.amazon.com" + ); + expect(buildConsentUrl("FE", "cid", "http://localhost:3000/callback").origin).toBe( + "https://apac.account.amazon.com" + ); + }); + + it("sets the OAuth query parameters", () => { + const url = buildConsentUrl("NA", "my-client", "http://localhost:3000/callback"); + expect(url.pathname).toBe("/ap/oa"); + expect(url.searchParams.get("client_id")).toBe("my-client"); + expect(url.searchParams.get("scope")).toBe("advertising::campaign_management"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3000/callback"); + }); +}); diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index 84a6154..28a9b25 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -1,5 +1,6 @@ import zlib from "zlib"; import { promisify } from "util"; +import { getTokenUrl, normalizeRegion } from "./lwa.js"; const gunzip = promisify(zlib.gunzip); @@ -23,6 +24,43 @@ export interface ReportSpec { filters?: unknown[]; } +interface RejectedItem { + index?: number; + errors?: { errorType?: string; errorValue?: unknown }[]; +} + +/** + * Amazon's v3/v4 batch endpoints (create/update/delete) return 207 with + * `{ : { success: [...], error: [...] } }`. Throw when any item was + * rejected so callers never report success for a failed write. + */ +function throwIfItemsRejected(body: unknown): void { + if (!body || typeof body !== "object" || Array.isArray(body)) return; + + for (const [entity, value] of Object.entries(body as Record)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const errors = (value as { error?: unknown }).error; + if (!Array.isArray(errors) || errors.length === 0) continue; + + const details = (errors as RejectedItem[]) + .map((item) => { + const reasons = (item.errors ?? []) + .map((e) => { + const type = e.errorType ?? "UNKNOWN_ERROR"; + const value = e.errorValue === undefined ? "" : ` ${JSON.stringify(e.errorValue)}`; + return `${type}${value}`; + }) + .join("; "); + return `item ${item.index ?? "?"}: ${reasons || "no details"}`; + }) + .join("\n"); + + throw new Error( + `Amazon Ads API rejected ${errors.length} ${entity} item(s):\n${details}` + ); + } +} + export class AmazonAdsClient { private config: AmazonAdsConfig; private accessToken?: string; @@ -81,7 +119,7 @@ export class AmazonAdsClient { client_secret: clientSecret, }); - const response = await fetch("https://api.amazon.com/auth/o2/token", { + const response = await fetch(getTokenUrl(normalizeRegion(this.config.region)), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -158,7 +196,11 @@ export class AmazonAdsClient { if (!text) { return { success: true, campaignId: path.split("/").pop() }; } - return JSON.parse(text); + const parsed: unknown = JSON.parse(text); + // Batch endpoints answer 207 Multi-Status and report per-item failures + // in the body, so an OK status alone does not mean the write succeeded. + throwIfItemsRejected(parsed); + return parsed; } const errorText = await response.text(); @@ -183,6 +225,42 @@ export class AmazonAdsClient { throw lastError || new Error("Request failed after retries"); } + /** + * POST a v3/v4 list endpoint and follow `nextToken` until every page has + * been fetched. Pages are merged under `key` so callers see one response. + */ + private async listAll( + path: string, + profileId: string, + body: Record, + contentType: string, + key: string + ): Promise { + const items: unknown[] = []; + let merged: Record = {}; + let nextToken: string | undefined; + + do { + const page = (await this.makeRequest( + "POST", + path, + profileId, + nextToken ? { ...body, nextToken } : body, + contentType + )) as Record | unknown[]; + + // Older endpoints answer with a bare array and no paging. + if (Array.isArray(page)) return page; + + const { nextToken: token, ...rest } = page; + merged = rest; + if (Array.isArray(page[key])) items.push(...(page[key] as unknown[])); + nextToken = typeof token === "string" && token ? token : undefined; + } while (nextToken); + + return { ...merged, [key]: items }; + } + async getProfiles(): Promise { return this.makeRequest("GET", "/v2/profiles"); } @@ -210,7 +288,7 @@ export class AmazonAdsClient { body.stateFilter = { include: [state.toUpperCase()] }; } - return this.makeRequest("POST", path, profileId, body, contentType); + return this.listAll(path, profileId, body, contentType, "campaigns"); } async getCampaignPerformance( @@ -274,12 +352,12 @@ export class AmazonAdsClient { // Step 3: Download and decompress the report const reportData = await this.downloadReport(statusResponse.url); - // Filter to requested campaign if specified + // Filter to the requested campaign. An empty result is correct here; + // never fall back to the whole account's rows. if (campaignId && Array.isArray(reportData)) { - const filtered = reportData.filter( + return reportData.filter( (row: Record) => String(row.campaignId) === campaignId ); - return filtered.length > 0 ? filtered : reportData; } return reportData; @@ -381,7 +459,7 @@ export class AmazonAdsClient { async getPortfolios(profileId: string): Promise { const contentType = "application/vnd.spPortfolio.v3+json"; - return this.makeRequest("POST", "/portfolios/list", profileId, {}, contentType); + return this.listAll("/portfolios/list", profileId, {}, contentType, "portfolios"); } private async downloadReport(url: string): Promise { @@ -444,7 +522,7 @@ export class AmazonAdsClient { body.adGroupIdFilter = { include: [adGroupId] }; } - return this.makeRequest("POST", path, profileId, body, contentType); + return this.listAll(path, profileId, body, contentType, "keywords"); } async updateKeywordBid( @@ -520,7 +598,7 @@ export class AmazonAdsClient { body.adGroupIdFilter = { include: [adGroupId] }; } - return this.makeRequest("POST", path, profileId, body, contentType); + return this.listAll(path, profileId, body, contentType, "productAds"); } async archiveCampaign( diff --git a/src/commands/auth.ts b/src/commands/auth.ts index d6b6252..ef354bf 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -4,9 +4,9 @@ import readline from "readline/promises"; import { Command } from "commander"; import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "../config.js"; import { printSuccess, printError, printWarning } from "../output.js"; +import { buildConsentUrl, getTokenUrl, normalizeRegion, REGIONS } from "../lwa.js"; const REDIRECT_URI = "http://localhost:3000/callback"; -const SCOPES = "advertising::campaign_management"; function maskSecret(value: string | undefined): string { if (!value) return "(not set)"; @@ -38,12 +38,13 @@ export function registerAuthCommands(program: Command): void { process.exit(1); } - // Build the authorization URL - const authUrl = new URL("https://www.amazon.com/ap/oa"); - authUrl.searchParams.set("client_id", clientId.trim()); - authUrl.searchParams.set("scope", SCOPES); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("redirect_uri", REDIRECT_URI); + // Region picks the Login with Amazon consent page, so ask before opening the browser. + const regionAnswer = await rl.question(`Region (${REGIONS.join("/")}) [NA]: `); + const region = normalizeRegion(regionAnswer); + const sandboxAnswer = await rl.question("Sandbox mode? (y/N): "); + const sandbox = sandboxAnswer.trim().toLowerCase() === "y"; + + const authUrl = buildConsentUrl(region, clientId.trim(), REDIRECT_URI); console.log("\nStarting local callback server on http://localhost:3000..."); @@ -86,7 +87,7 @@ export function registerAuthCommands(program: Command): void { client_secret: clientSecret.trim(), }); - const response = await fetch("https://api.amazon.com/auth/o2/token", { + const response = await fetch(getTokenUrl(region), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: tokenData.toString(), @@ -102,16 +103,8 @@ export function registerAuthCommands(program: Command): void { res.writeHead(200, { "Content-Type": "text/html" }); res.end(htmlPage("Success!", "Your token has been saved. You can close this window.")); - server.close(async () => { + server.close(() => { try { - const regionAnswer = await rl.question( - "\nRegion (NA/EU/FE) [NA]: " - ); - const sandboxAnswer = await rl.question("Sandbox mode? (y/N): "); - - const region = regionAnswer.trim().toUpperCase() || "NA"; - const sandbox = sandboxAnswer.trim().toLowerCase() === "y"; - const existing = loadConfig(); saveConfig({ ...existing, diff --git a/src/config.ts b/src/config.ts index 25fe157..f362c42 100644 --- a/src/config.ts +++ b/src/config.ts @@ -55,8 +55,12 @@ export function saveConfig(config: CliConfig): void { const configPath = getConfigPath(); const dir = path.dirname(configPath); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); + // The file holds the client secret and refresh token: owner-only access. + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o600 }); + // writeFileSync only applies mode on create; tighten an existing file too. + fs.chmodSync(configPath, 0o600); } export function deleteConfig(): void { diff --git a/src/lwa.ts b/src/lwa.ts new file mode 100644 index 0000000..08d3c57 --- /dev/null +++ b/src/lwa.ts @@ -0,0 +1,44 @@ +/** + * Login with Amazon (LwA) endpoints. The consent page and token service are + * served from regional hosts; tokens issued by any region are valid globally. + * https://advertising.amazon.com/API/docs/en-us/guides/account-management/authorization/authorization-grants + */ + +export type Region = "NA" | "EU" | "FE"; + +export const REGIONS: Region[] = ["NA", "EU", "FE"]; + +const CONSENT_HOSTS: Record = { + NA: "https://www.amazon.com", + EU: "https://eu.account.amazon.com", + FE: "https://apac.account.amazon.com", +}; + +const TOKEN_HOSTS: Record = { + NA: "https://api.amazon.com", + EU: "https://api.amazon.co.uk", + FE: "https://api.amazon.co.jp", +}; + +export const SCOPE = "advertising::campaign_management"; + +/** Upper-cases and validates a region string; empty input means NA. */ +export function normalizeRegion(input: string | undefined): Region { + const value = (input ?? "").trim().toUpperCase(); + if (!value) return "NA"; + if ((REGIONS as string[]).includes(value)) return value as Region; + throw new Error(`Unknown region "${input}". Use one of: ${REGIONS.join(", ")}`); +} + +export function getTokenUrl(region: Region): string { + return `${TOKEN_HOSTS[region]}/auth/o2/token`; +} + +export function buildConsentUrl(region: Region, clientId: string, redirectUri: string): URL { + const url = new URL("/ap/oa", CONSENT_HOSTS[region]); + url.searchParams.set("client_id", clientId); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", redirectUri); + return url; +}