diff --git a/.worktreeinclude b/.worktreeinclude index 079eec2..8152b36 100644 --- a/.worktreeinclude +++ b/.worktreeinclude @@ -1,2 +1,3 @@ /.env +/.env.live-tokens /.env.local diff --git a/docs/TESTING.md b/docs/TESTING.md index 9d1f648..62bd223 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -109,6 +109,7 @@ Live tests stay separate on purpose: Default local env files, loaded in order: - direct process environment +- `.env.live-tokens` - `.env.local` - `.env` @@ -151,10 +152,9 @@ Optional direct runtime variables: `PUTIO_LIVE_OWNED_VIDEO_FILE_ID` can pin media live tests to an explicit safe, owned, unshared MP4 fixture. If it is unset, the live harness only accepts -owned MP4s with SDK/example fixture names such as `codex_sdk_*`, -`codex-sdk-*`, `Mario1_507_512kb.mp4`, `Sintel.mp4`, or -`Big Buck Bunny.mp4`; it never selects an arbitrary private video from the -account. +owned MP4s with SDK/example fixture names such as `putio-typescript-sdk-*`, +`Mario1_507_512kb.mp4`, `Sintel.mp4`, or `Big Buck Bunny.mp4`; it never selects +an arbitrary private video from the account. `PUTIO_LIVE_RSS_SOURCE_URL` must point at a known-good RSS feed when running the `rss` target. `PUTIO_TOKEN_PAYMENT_OWNER` must belong to a prepaid owner account @@ -168,6 +168,15 @@ safe owned MP4 fixture for media flag, URL, HLS, watch status, and start-from coverage. The shared-friend clone fixture is seeded from the configured secondary account. +File and transfer tests create timestamped `putio-typescript-sdk-*` resources. +Each test removes its archive, extracted files, or transfer before it exits. +Torrent fixtures use an unreachable `example.invalid` tracker. URL fixtures +cover the terminal error and retry states. + +Uploaded torrents did not create a predictable history event. The suite tests +the missing-event result from `events.getTorrent(...)` and leaves existing +account history alone. + Use `pnpm secrets:setup` to validate the maintainer-provided SOPS ciphertext and render shared live variables into `.env.local`. The live harness also accepts legacy local aliases when they are already exported in the shell. @@ -188,34 +197,38 @@ Single target: vp pack && vp test run --config vitest.live.config.ts test/live/auth.test.ts ``` -Run explicit targets with fresh runtime tokens: +Run explicit targets with the provisioned runtime tokens: ```bash -pnpm test:live:fresh -- test/live/account.test.ts test/live/tunnel.test.ts +pnpm test:live:targets -- test/live/account.test.ts test/live/tunnel.test.ts ``` -`test:live:fresh` uses the existing credential fixture to mint runtime tokens, -runs only the named test files, and revokes the fresh first-party session before -exiting, including when a test fails. It never writes the runtime tokens to an -env file. +`test:live:targets` runs only the named files. It reads +`PUTIO_TOKEN_FIRST_PARTY` and `PUTIO_TOKEN_THIRD_PARTY` and never calls password +login. It rejects `auth-credentials`, `family`, `friend-invites`, `friends`, +`podcast`, and `sharing` because those targets bootstrap account credentials. + +`pnpm bootstrap:tokens` writes new tokens to the ignored `0600` +`.env.live-tokens` cache. Live commands load it before `.env.local`. Bootstrap +refuses to replace the cache unless you pass `--refresh`. An unattended runner with a scoped age identity can run a command without materializing secrets: ```bash sops exec-env --same-process "$PUTIO_SDK_TYPESCRIPT_SOPS_FILE" \ - 'pnpm test:live:fresh -- test/live/account.test.ts test/live/tunnel.test.ts' + 'pnpm test:live:targets -- test/live/account.test.ts test/live/tunnel.test.ts' ``` Run `pnpm secrets:setup` once per worktree with `PUTIO_SDK_TYPESCRIPT_SOPS_FILE` pointing to the supplied ciphertext. The -materialized file is `0600` and gitignored. Live commands auto-load -`.env.local` first and then `.env`; already-exported environment variables keep -highest priority. +materialized file is `0600` and gitignored. Live commands load +`.env.live-tokens`, `.env.local`, and `.env` in that order. Exported environment +variables keep highest priority. ```bash pnpm secrets:setup # one-time per worktree -pnpm bootstrap:tokens # mints fresh tokens +pnpm bootstrap:tokens # mints and caches tokens once pnpm bootstrap:live-fixtures pnpm test:live # runs the broader live suite against pre-existing tokens pnpm secrets:clean # before `git worktree remove` diff --git a/package.json b/package.json index 0207c98..daa3073 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "lint:unused:prod": "vp pack && knip --production --no-gitignore", "prepack": "vp pack", "secrets:setup": "bash ./scripts/secrets-setup.sh", - "secrets:clean": "rm -f .env.local .env.local.* .env.local.swp", + "secrets:clean": "rm -f .env.live-tokens .env.local .env.local.* .env.local.swp", "test": "vp test --passWithNoTests", "test:compat": "node ./scripts/test-compat-node.ts && node ./scripts/test-compat-browser.ts && node ./scripts/test-compat-bun.ts", "test:compat:browser": "node ./scripts/test-compat-browser.ts", @@ -56,7 +56,7 @@ "test:compat:bun": "node ./scripts/test-compat-bun.ts", "test:compat:node": "node ./scripts/test-compat-node.ts", "test:live": "vp pack && vp test run --config vitest.live.config.ts", - "test:live:fresh": "vp pack && node ./scripts/test-live-fresh.ts", + "test:live:targets": "vp pack && node ./scripts/test-live-targets.ts", "validate:routes": "vp pack && vp run validate:routes:packed", "validate:routes:packed": "node ./scripts/validate-route-matrix.ts", "verify": "vp check . && vp run lint:package && knip && knip --production --no-gitignore && vp run validate:routes:packed && vp test run --coverage --passWithNoTests" diff --git a/scripts/bootstrap-live-fixtures.ts b/scripts/bootstrap-live-fixtures.ts index 111794c..2c625d0 100644 --- a/scripts/bootstrap-live-fixtures.ts +++ b/scripts/bootstrap-live-fixtures.ts @@ -224,7 +224,7 @@ await runCheck("rss source fixture", async () => { const created = await primaryClient.rss.create({ dont_process_whole_feed: true, rss_source_url: rssSourceUrl, - title: `codex sdk rss fixture ${Date.now()}`, + title: `putio-typescript-sdk rss fixture ${Date.now()}`, }); try { diff --git a/scripts/bootstrap-tokens.ts b/scripts/bootstrap-tokens.ts index 2c43062..3e76d9a 100644 --- a/scripts/bootstrap-tokens.ts +++ b/scripts/bootstrap-tokens.ts @@ -1,8 +1,18 @@ import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { liveTokenCacheUrl, writeLiveTokenCache } from "./live-token-cache.ts"; import { bootstrapRuntimeTokens } from "../test/live/support/bootstrap.ts"; import { readBootstrapSecrets } from "../test/live/support/secrets.ts"; +const refresh = process.argv.slice(2).includes("--refresh"); + +if (existsSync(liveTokenCacheUrl) && !refresh) { + throw new Error( + "Live tokens are already cached; use test:live or pass --refresh to replace expired tokens", + ); +} + const packageDir = new URL("..", import.meta.url); const buildResult = spawnSync("vp", ["pack"], { @@ -21,6 +31,11 @@ const bootstrapped = await bootstrapRuntimeTokens(secrets, async (config = {}) = createPutioSdkPromiseClient(config), ); +await writeLiveTokenCache(liveTokenCacheUrl, { + firstPartyToken: bootstrapped.firstParty.accessToken, + thirdPartyToken: bootstrapped.thirdParty.accessToken, +}); + console.log( JSON.stringify( { diff --git a/scripts/live-targets.spec.ts b/scripts/live-targets.spec.ts new file mode 100644 index 0000000..d3b0cec --- /dev/null +++ b/scripts/live-targets.spec.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import { resolveLiveTestTargets } from "./live-targets.ts"; + +describe("resolveLiveTestTargets", () => { + it("accepts relative and absolute paths under test/live", async () => { + const cwd = await mkdtemp(join(tmpdir(), "putio-sdk-live-targets-")); + const target = join(cwd, "test/live/files.test.ts"); + + try { + await mkdir(join(cwd, "test/live"), { recursive: true }); + await writeFile(target, ""); + + expect(resolveLiveTestTargets(["./test/live/files.test.ts", target], cwd)).toEqual([ + target, + target, + ]); + } finally { + await rm(cwd, { force: true, recursive: true }); + } + }); + + it("rejects test files outside test/live", async () => { + const cwd = await mkdtemp(join(tmpdir(), "putio-sdk-live-targets-")); + const target = join(cwd, "test/files.test.ts"); + + try { + await mkdir(join(cwd, "test"), { recursive: true }); + await writeFile(target, ""); + + expect(() => resolveLiveTestTargets([target], cwd)).toThrow( + `Unsupported live test target: ${target}`, + ); + } finally { + await rm(cwd, { force: true, recursive: true }); + } + }); + + it("rejects targets that log in with account credentials", async () => { + const cwd = await mkdtemp(join(tmpdir(), "putio-sdk-live-targets-")); + const target = join(cwd, "test/live/podcast.test.ts"); + + try { + await mkdir(join(cwd, "test/live"), { recursive: true }); + await writeFile(target, ""); + + expect(() => resolveLiveTestTargets([target], cwd)).toThrow( + `Credential-backed target is not allowed: ${target}`, + ); + } finally { + await rm(cwd, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/live-targets.ts b/scripts/live-targets.ts new file mode 100644 index 0000000..53a61dd --- /dev/null +++ b/scripts/live-targets.ts @@ -0,0 +1,38 @@ +import { existsSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +const CREDENTIAL_BACKED_TARGETS = new Set([ + "auth-credentials.test.ts", + "family.test.ts", + "friend-invites.test.ts", + "friends.test.ts", + "podcast.test.ts", + "sharing.test.ts", +]); + +export const resolveLiveTestTargets = ( + targets: ReadonlyArray, + cwd = process.cwd(), +): ReadonlyArray => { + const liveRoot = resolve(cwd, "test/live"); + + return targets.map((target) => { + const resolvedTarget = resolve(cwd, target); + const relativeTarget = relative(liveRoot, resolvedTarget); + const isLiveTest = + relativeTarget.endsWith(".test.ts") && + relativeTarget !== ".." && + !relativeTarget.startsWith(`..${sep}`) && + !isAbsolute(relativeTarget); + + if (!isLiveTest || !existsSync(resolvedTarget)) { + throw new Error(`Unsupported live test target: ${target}`); + } + + if (CREDENTIAL_BACKED_TARGETS.has(relativeTarget)) { + throw new Error(`Credential-backed target is not allowed: ${target}`); + } + + return resolvedTarget; + }); +}; diff --git a/scripts/live-token-cache.spec.ts b/scripts/live-token-cache.spec.ts new file mode 100644 index 0000000..1e93093 --- /dev/null +++ b/scripts/live-token-cache.spec.ts @@ -0,0 +1,29 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vite-plus/test"; + +import { writeLiveTokenCache } from "./live-token-cache.ts"; + +describe("live token cache", () => { + it("writes dotenv tokens with 0600 permissions", async () => { + const directory = await mkdtemp(join(tmpdir(), "putio-sdk-live-tokens-")); + try { + const url = pathToFileURL(join(directory, ".env.live-tokens")); + await writeLiveTokenCache(url, { + firstPartyToken: "first-test-token", + thirdPartyToken: "third-test-token", + }); + + expect(await readFile(url, "utf8")).toBe( + 'PUTIO_TOKEN_FIRST_PARTY="first-test-token"\n' + + 'PUTIO_TOKEN_THIRD_PARTY="third-test-token"\n', + ); + expect((await stat(url)).mode & 0o777).toBe(0o600); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/live-token-cache.ts b/scripts/live-token-cache.ts new file mode 100644 index 0000000..404133c --- /dev/null +++ b/scripts/live-token-cache.ts @@ -0,0 +1,20 @@ +import { chmod, writeFile } from "node:fs/promises"; + +export const liveTokenCacheUrl = new URL("../.env.live-tokens", import.meta.url); + +export const writeLiveTokenCache = async ( + url: URL, + tokens: { + readonly firstPartyToken: string; + readonly thirdPartyToken: string; + }, +): Promise => { + const contents = [ + `PUTIO_TOKEN_FIRST_PARTY=${JSON.stringify(tokens.firstPartyToken)}`, + `PUTIO_TOKEN_THIRD_PARTY=${JSON.stringify(tokens.thirdPartyToken)}`, + "", + ].join("\n"); + + await writeFile(url, contents, { mode: 0o600 }); + await chmod(url, 0o600); +}; diff --git a/scripts/test-live-fresh.ts b/scripts/test-live-fresh.ts deleted file mode 100644 index 280d1ee..0000000 --- a/scripts/test-live-fresh.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { relative, resolve, sep } from "node:path"; - -import { - bootstrapFirstPartyToken, - bootstrapThirdPartyToken, -} from "../test/live/support/bootstrap.ts"; -import { readBootstrapSecrets } from "../test/live/support/secrets.ts"; - -const args = process.argv.slice(2); -const targets = args[0] === "--" ? args.slice(1) : args; - -if (targets.length === 0) { - throw new Error("Pass one or more explicit test/live/**/*.test.ts files"); -} - -const liveRoot = resolve("test/live"); - -for (const target of targets) { - const relativeTarget = relative(liveRoot, resolve(target)); - const isLiveTest = - target.startsWith("test/live/") && - target.endsWith(".test.ts") && - relativeTarget !== ".." && - !relativeTarget.startsWith(`..${sep}`); - - if (!isLiveTest || !existsSync(target)) { - throw new Error(`Unsupported live test target: ${target}`); - } -} - -const { createPutioSdkPromiseClient } = await import("../dist/index.js"); -const clients = new Set>(); -const createClient = async (config: Record = {}) => { - const client = createPutioSdkPromiseClient(config); - clients.add(client); - return client; -}; -const secrets = readBootstrapSecrets(); -let firstParty: Awaited> | null = null; -let cleanupError: Error | null = null; -let runError: Error | null = null; -let status = 1; - -try { - firstParty = await bootstrapFirstPartyToken(secrets, createClient); - const thirdParty = await bootstrapThirdPartyToken( - firstParty.accessToken, - secrets.thirdPartyClientId, - createClient, - ); - const result = spawnSync("vp", ["test", "run", "--config", "vitest.live.config.ts", ...targets], { - env: { - ...process.env, - PUTIO_TOKEN_FIRST_PARTY: firstParty.accessToken, - PUTIO_TOKEN_THIRD_PARTY: thirdParty.accessToken, - }, - stdio: "inherit", - }); - - runError = result.error ?? null; - status = result.status ?? 1; -} catch (error) { - runError = error instanceof Error ? error : new Error("Unknown live test failure"); -} finally { - try { - if (firstParty !== null) { - if (firstParty.tokenId === null) { - cleanupError = new Error("Fresh first-party session did not return a token ID"); - } else { - const cleanupClient = await createClient({ accessToken: firstParty.accessToken }); - await cleanupClient.auth.revokeClient(firstParty.tokenId); - } - } - } catch (error) { - cleanupError = error instanceof Error ? error : new Error("Unknown token cleanup failure"); - } - - for (const client of clients) { - try { - await client.dispose(); - } catch (error) { - cleanupError ??= - error instanceof Error ? error : new Error("Unknown client disposal failure"); - } - } -} - -if (runError) { - console.error(`Live test execution failed: ${runError.message}`); -} - -if (cleanupError) { - console.error(`Fresh token cleanup failed: ${cleanupError.message}`); - process.exit(1); -} - -process.exit(status); diff --git a/scripts/test-live-targets.ts b/scripts/test-live-targets.ts new file mode 100644 index 0000000..4d09a3f --- /dev/null +++ b/scripts/test-live-targets.ts @@ -0,0 +1,33 @@ +import { spawnSync } from "node:child_process"; + +import { resolveLiveTestTargets } from "./live-targets.ts"; +import { readLiveTokens } from "../test/live/support/secrets.ts"; + +const args = process.argv.slice(2); +const targets = args[0] === "--" ? args.slice(1) : args; + +if (targets.length === 0) { + throw new Error("Pass one or more explicit test/live/**/*.test.ts files"); +} + +const resolvedTargets = resolveLiveTestTargets(targets); + +const tokens = readLiveTokens(); +const result = spawnSync( + "vp", + ["test", "run", "--config", "vitest.live.config.ts", ...resolvedTargets], + { + env: { + ...process.env, + PUTIO_TOKEN_FIRST_PARTY: tokens.firstPartyToken, + PUTIO_TOKEN_THIRD_PARTY: tokens.thirdPartyToken, + }, + stdio: "inherit", + }, +); + +if (result.error) { + console.error(`Live test execution failed: ${result.error.message}`); +} + +process.exit(result.status ?? 1); diff --git a/src/domains/auth.spec.ts b/src/domains/auth.spec.ts index f4a22c3..0ea4e02 100644 --- a/src/domains/auth.spec.ts +++ b/src/domains/auth.spec.ts @@ -48,12 +48,12 @@ describe("auth domain", () => { expect( buildAuthLoginUrl({ clientId: 42, - clientName: "Codex", + clientName: "putio-typescript-sdk", redirectUri: "https://example.com/callback", state: "state-123", }), ).toBe( - "https://app.put.io/authenticate?client_id=42&client_name=Codex&isolated=1&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=token&state=state-123", + "https://app.put.io/authenticate?client_id=42&client_name=putio-typescript-sdk&isolated=1&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=token&state=state-123", ); }); @@ -144,7 +144,7 @@ describe("auth domain", () => { login({ callbackUrl: "https://example.com/callback", clientId: 42, - clientName: "Codex", + clientName: "putio-typescript-sdk", clientSecret: "secret", fingerprint: "fingerprint-1", password: "pass", @@ -152,7 +152,7 @@ describe("auth domain", () => { }), (request) => { expect(request.url).toBe( - "https://api.put.io/v2/oauth2/authorizations/clients/42/fingerprint-1?callback_url=https%3A%2F%2Fexample.com%2Fcallback&client_name=Codex&client_secret=secret", + "https://api.put.io/v2/oauth2/authorizations/clients/42/fingerprint-1?callback_url=https%3A%2F%2Fexample.com%2Fcallback&client_name=putio-typescript-sdk&client_secret=secret", ); expect(getAuthorizationHeader(request)).toBe("Basic c2RrOnBhc3M="); diff --git a/test/live/account.test.ts b/test/live/account.test.ts index 929e6cc..93ba06c 100644 --- a/test/live/account.test.ts +++ b/test/live/account.test.ts @@ -70,7 +70,7 @@ describe.sequential("account live", () => { test("invalid callback url yields a typed operation error", async () => { await expect( clients.authClient.account.saveSettings({ - callback_url: "codex-invalid-callback", + callback_url: "putio-typescript-sdk-invalid-callback", }), ).rejects.toMatchObject({ _tag: "PutioOperationError", diff --git a/test/live/domains/config.ts b/test/live/domains/config.ts index 22ce969..2aa2f7c 100644 --- a/test/live/domains/config.ts +++ b/test/live/domains/config.ts @@ -5,7 +5,7 @@ const { oauthClient } = await createClients({ }); const now = new Date().toISOString(); -const probeKey = "codex_sdk_config_probe"; +const probeKey = "putio-typescript-sdk-config-probe"; const probeValue = { enabled: true, tags: ["config", "effect", "sdk"], diff --git a/test/live/domains/download-links.ts b/test/live/domains/download-links.ts index a1074ee..1a812fe 100644 --- a/test/live/domains/download-links.ts +++ b/test/live/domains/download-links.ts @@ -87,9 +87,13 @@ const findProbeFile = async (): Promise => { } const upload = await client.files.upload({ - file: new File(["sdk download-links probe\n"], `codex_sdk_download_links_${Date.now()}.txt`, { - type: "text/plain", - }), + file: new File( + ["sdk download-links probe\n"], + `putio-typescript-sdk-download-links-${Date.now()}.txt`, + { + type: "text/plain", + }, + ), parentId: 0, }); @@ -104,7 +108,7 @@ const createCursorProbeFiles = async () => { const ids: number[] = []; for (let index = 0; index < 2; index += 1) { - const name = `codex_sdk_download_links_cursor_${Date.now()}_${index}.txt`; + const name = `putio-typescript-sdk-download-links-cursor-${Date.now()}-${index}.txt`; const upload = await client.files.upload({ file: new File([`sdk download-links cursor probe ${index}\n`], name, { type: "text/plain", diff --git a/test/live/domains/events.ts b/test/live/domains/events.ts index fe94d65..7cb0ab6 100644 --- a/test/live/domains/events.ts +++ b/test/live/domains/events.ts @@ -1,4 +1,4 @@ -import { assertPresent, createClients, createLiveHarness } from "../support/harness.js"; +import { createClients, createLiveHarness } from "../support/harness.js"; const { authClient, oauthClient } = await createClients({ authClient: "PUTIO_TOKEN_FIRST_PARTY", @@ -6,10 +6,7 @@ const { authClient, oauthClient } = await createClients({ }); const live = createLiveHarness("events live"); -const { assert, assertOperationError, finish, run, sleep } = live; - -void assertOperationError; -void sleep; +const { assert, assertErrorTag, assertOperationError, finish, run } = live; await run("events list shape", async () => { const result = await oauthClient.events.list({ @@ -96,12 +93,7 @@ await run("events list invalid per_page yields typed error", async () => { }); throw new Error("expected invalid per_page to fail"); } catch (error) { - return assertOperationError(error, { - domain: "events", - errorType: "INVALID_PER_PAGE", - operation: "list", - statusCode: 400, - }); + return assertErrorTag(error, { tag: "PutioValidationError" }); } }); @@ -152,29 +144,4 @@ await run("events torrent missing id yields typed error", async () => { } }); -await run("events torrent for non-upload event currently yields 404", async () => { - const result = await oauthClient.events.list({ - per_page: 20, - }); - const nonUpload = assertPresent( - result.events.find((event) => event.type !== "upload"), - "expected at least one non-upload event for torrent probe", - ); - - try { - await oauthClient.events.getTorrent(nonUpload.id); - throw new Error("expected non-upload torrent lookup to fail"); - } catch (error) { - return { - event_type: nonUpload.type, - id: nonUpload.id, - ...assertOperationError(error, { - domain: "events", - operation: "getTorrent", - statusCode: 404, - }), - }; - } -}); - finish(); diff --git a/test/live/domains/family.ts b/test/live/domains/family.ts index e19cc10..36d96ff 100644 --- a/test/live/domains/family.ts +++ b/test/live/domains/family.ts @@ -156,7 +156,7 @@ await run("family positive invite lookup with secondary fixture", async () => { await run("family remove missing member yields 404", async () => { try { - await authClient.family.removeMember("codex-no-such-family-member"); + await authClient.family.removeMember("putio-typescript-sdk-no-such-family-member"); throw new Error("expected remove missing family member to fail"); } catch (error) { return assertOperationError(error, { @@ -169,7 +169,7 @@ await run("family remove missing member yields 404", async () => { await run("family remove member requires restricted scope for oauth token", async () => { try { - await oauthClient.family.removeMember("codex-no-such-family-member"); + await oauthClient.family.removeMember("putio-typescript-sdk-no-such-family-member"); throw new Error("expected app-token removeMember to fail"); } catch (error) { return assertOperationError(error, { @@ -183,7 +183,7 @@ await run("family remove member requires restricted scope for oauth token", asyn await run("family join bogus code yields known 403", async () => { try { - await authClient.family.join("codex-invalid-family-code"); + await authClient.family.join("putio-typescript-sdk-invalid-family-code"); throw new Error("expected join with bogus code to fail"); } catch (error) { const operationError = expectOperationError(error); @@ -216,7 +216,7 @@ await run("family join bogus code yields known 403", async () => { await run("family join requires restricted scope for oauth token", async () => { try { - await oauthClient.family.join("codex-invalid-family-code"); + await oauthClient.family.join("putio-typescript-sdk-invalid-family-code"); throw new Error("expected join with app token to fail"); } catch (error) { return assertOperationError(error, { diff --git a/test/live/domains/file-direct.ts b/test/live/domains/file-direct.ts index 49e2f12..064624a 100644 --- a/test/live/domains/file-direct.ts +++ b/test/live/domains/file-direct.ts @@ -12,7 +12,7 @@ void assertOperationError; void sleep; const createDisposableTextFile = async (label: string) => { - const name = `codex_sdk_${label}_${Date.now()}.txt`; + const name = `putio-typescript-sdk-${label}-${Date.now()}.txt`; const upload = await client.files.upload({ file: new File(["sdk live probe\n"], name, { type: "text/plain", @@ -102,7 +102,7 @@ await run("files api mp4 download url redirects for owned video", async () => { }); const url = await client.files.getApiMp4DownloadUrl(video.id, { - name: "codex-sdk-live.mp4", + name: "putio-typescript-sdk-live.mp4", }); const response = await fetch(url, { redirect: "manual", @@ -154,7 +154,7 @@ await run("files XSPF playlist is fetchable for owned video", async () => { }); await run("files upload works through upload.put.io", async () => { - const name = `codex_sdk_upload_probe_${Date.now()}.txt`; + const name = `putio-typescript-sdk-upload-probe-${Date.now()}.txt`; const upload = await client.files.upload({ file: new File(["sdk upload probe\n"], name, { type: "text/plain", diff --git a/test/live/domains/file-tasks.ts b/test/live/domains/file-tasks.ts index e5868e7..3090330 100644 --- a/test/live/domains/file-tasks.ts +++ b/test/live/domains/file-tasks.ts @@ -4,6 +4,7 @@ import { createLiveHarness, isFileUploadFileResult, } from "../support/harness.js"; +import { createStoredZipFile } from "../support/binary-fixtures.ts"; import { requireOwnedVideoFixture } from "../support/media.ts"; const { authClient, client } = await createClients({ @@ -14,18 +15,18 @@ const { authClient, client } = await createClients({ const live = createLiveHarness("file-tasks live"); const { assert, assertOperationError, finish, run, sleep } = live; -void assertOperationError; -void sleep; +const waitForExtractionTerminalState = async (id: number) => { + for (let attempt = 0; attempt < 30; attempt += 1) { + const extraction = (await client.files.listExtractions()).find((item) => item.id === id); -const getArchiveCandidate = async () => { - const search = await client.files.search({ - per_page: 20, - query: "zip", - }); + if (extraction?.status === "EXTRACTED" || extraction?.status === "ERROR") { + return extraction; + } + + await sleep(1_000); + } - return ( - search.files.find((file) => file.file_type === "ARCHIVE" && file.is_shared === false) ?? null - ); + throw new Error(`timed out waiting for extraction ${id} to reach a terminal state`); }; await run("files list extractions shape", async () => { @@ -62,11 +63,6 @@ await run("files setWatchStatus roundtrip", async () => { watched: false, }); } - - return { - checked: true, - video_id: video.id, - }; }); await run("files start_from roundtrip semantics", async () => { @@ -90,13 +86,6 @@ await run("files start_from roundtrip semantics", async () => { const restored = await client.files.getStartFrom(video.id); assert(restored === before, "expected start_from to be restored"); - - return { - before, - restored, - updated, - video_id: video.id, - }; } finally { await client.files.setStartFrom({ file_id: video.id, @@ -107,16 +96,17 @@ await run("files start_from roundtrip semantics", async () => { await run("files next-file natural ordering with disposable fixtures", async () => { const created: Array<{ readonly id: number; readonly name: string }> = []; + const suffix = Date.now(); const folder = await authClient.files.createFolder({ - name: `codex_sdk_next_file_${Date.now()}`, + name: `putio-typescript-sdk-next-file-${suffix}`, parent_id: 0, }); try { for (const name of [ - `codex_sdk_episode_1_${Date.now()}.txt`, - `codex_sdk_episode_10_${Date.now()}.txt`, - `codex_sdk_episode_2_${Date.now()}.txt`, + `putio-typescript-sdk-episode-1-${suffix}.txt`, + `putio-typescript-sdk-episode-10-${suffix}.txt`, + `putio-typescript-sdk-episode-2-${suffix}.txt`, ]) { const upload = await authClient.files.upload({ file: new File(["sdk next-file probe\n"], name, { @@ -133,23 +123,22 @@ await run("files next-file natural ordering with disposable fixtures", async () } const episode1 = assertPresent( - created.find((file) => file.name.includes("_episode_1_")), + created.find((file) => file.name.includes("-episode-1-")), "expected episode 1 fixture", ); const episode2 = assertPresent( - created.find((file) => file.name.includes("_episode_2_")), + created.find((file) => file.name.includes("-episode-2-")), "expected episode 2 fixture", ); - const next = await client.files.findNext(episode1.id, "FILE"); + let next = await client.files.findNext(episode1.id, "FILE"); - assert(next.id === episode2.id, "expected natural ordering to skip episode 10"); + for (let attempt = 0; next.id !== episode2.id && attempt < 10; attempt += 1) { + await sleep(500); + next = await client.files.findNext(episode1.id, "FILE"); + } - return { - from: episode1.name, - next: next.name, - next_id: next.id, - }; + assert(next.id === episode2.id, "expected natural ordering to skip episode 10"); } finally { await authClient.files.delete([folder.id], { skipTrash: true, @@ -157,36 +146,43 @@ await run("files next-file natural ordering with disposable fixtures", async () } }); -await run("files extract and cleanup", async () => { - const archive = await getArchiveCandidate(); - - if (!archive) { - throw new Error("expected owned archive candidate"); - } - - const created = await client.files.extract({ - ids: [archive.id], +await run("files extract an owned archive", async () => { + const name = `putio-typescript-sdk-extract-${Date.now()}.zip`; + const upload = await authClient.files.upload({ + file: createStoredZipFile(name), + fileName: name, + parentId: 0, }); - assert(Array.isArray(created), "expected extraction result array"); + if (!isFileUploadFileResult(upload)) { + throw new Error("expected archive upload to return a file"); + } - const listed = await client.files.listExtractions(); - const createdIds = created.map((item) => item.id); + let extractionId: number | undefined; + const extractedFileIds: number[] = []; - for (const extractionId of createdIds) { - await client.files.deleteExtraction(extractionId); + try { + const created = await client.files.extract({ ids: [upload.file.id] }); + assert(created.length === 1, "expected one extraction task"); + const extraction = assertPresent(created[0], "expected extraction task"); + extractionId = extraction.id; + + const terminal = await waitForExtractionTerminalState(extraction.id); + assert(terminal.status === "EXTRACTED", "expected extraction to succeed"); + extractedFileIds.push(...terminal.files); + } finally { + if (extractionId !== undefined) { + await client.files.deleteExtraction(extractionId).catch(() => undefined); + } + await authClient.files + .delete([upload.file.id, ...extractedFileIds], { skipTrash: true }) + .catch(() => undefined); } - - return { - archive_id: archive.id, - created_count: created.length, - listed_match_count: listed.filter((item) => createdIds.includes(item.id)).length, - }; }); await run("files mp4 status on folder yields typed not-file", async () => { const folder = await authClient.files.createFolder({ - name: `codex_sdk_mp4_status_folder_${Date.now()}`, + name: `putio-typescript-sdk-mp4-status-folder-${Date.now()}`, parent_id: 0, }); diff --git a/test/live/domains/files.ts b/test/live/domains/files.ts index 295f70f..fdd4117 100644 --- a/test/live/domains/files.ts +++ b/test/live/domains/files.ts @@ -96,7 +96,9 @@ await run("files root list shape", async () => { }); await run("files list continue", async () => { - const probe = await createCursorProbeFolder(`codex_sdk_files_list_cursor_${Date.now()}`); + const probe = await createCursorProbeFolder( + `putio-typescript-sdk-files-list-cursor-${Date.now()}`, + ); try { const firstPage = await oauthClient.files.list(probe.folderId, { @@ -139,7 +141,7 @@ await run("files shared-with-you list", async () => { await run("files search and continue", async () => { const seed = Date.now(); - const query = `codex_sdk_files_search_cursor_${seed}`; + const query = `putio-typescript-sdk-files-search-cursor-${seed}`; const probe = await createCursorProbeFolder(query, 6); try { @@ -394,8 +396,8 @@ await run("files next-file and next-video", async () => { await run("files folder lifecycle", async () => { const suffix = Date.now(); - const folderAName = `codex_sdk_files_a_${suffix}`; - const folderBName = `codex_sdk_files_b_${suffix}`; + const folderAName = `putio-typescript-sdk-files-a-${suffix}`; + const folderBName = `putio-typescript-sdk-files-b-${suffix}`; const folderARenamed = `${folderAName}_renamed`; const folderA = await authClient.files.createFolder({ @@ -493,7 +495,7 @@ await run("empty folder name yields typed error", async () => { await run("folder mp4 status yields typed error", async () => { const folder = await authClient.files.createFolder({ - name: `codex_sdk_mp4_status_folder_${Date.now()}`, + name: `putio-typescript-sdk-mp4-status-folder-${Date.now()}`, parent_id: 0, }); diff --git a/test/live/domains/ifttt.ts b/test/live/domains/ifttt.ts index 11ae8f4..45adee4 100644 --- a/test/live/domains/ifttt.ts +++ b/test/live/domains/ifttt.ts @@ -30,7 +30,7 @@ await run("ifttt sendEvent negative behavior", async () => { try { await authClient.ifttt.sendEvent({ - eventType: "codex_invalid_event", + eventType: "putio-typescript-sdk-invalid-event", ingredients: { file_id: 1, }, @@ -64,7 +64,7 @@ await run("ifttt sendEvent rejects missing playback ingredients before transport eventType: "playback_started", ingredients: { file_id: 1, - file_name: "codex.mp4", + file_name: "putio-typescript-sdk.mp4", }, }); throw new Error("expected missing ingredients to fail"); @@ -79,7 +79,7 @@ await run("ifttt sendEvent requires restricted scope for oauth token", async () eventType: "playback_started", ingredients: { file_id: 1, - file_name: "codex.mp4", + file_name: "putio-typescript-sdk.mp4", file_type: "VIDEO", }, }); @@ -101,7 +101,7 @@ await run("ifttt valid playback event currently succeeds even when disabled", as eventType: "playback_started", ingredients: { file_id: 1, - file_name: "codex.mp4", + file_name: "putio-typescript-sdk.mp4", file_type: "VIDEO", }, }); diff --git a/test/live/domains/payment.ts b/test/live/domains/payment.ts index ab82a36..7ffa0e1 100644 --- a/test/live/domains/payment.ts +++ b/test/live/domains/payment.ts @@ -175,7 +175,7 @@ await run("payment invites require restricted scope", async () => { await run("payment fastspring confirm requires restricted scope", async () => { try { - await oauthClient.payment.confirmFastspringOrder("codex-bogus-reference"); + await oauthClient.payment.confirmFastspringOrder("putio-typescript-sdk-bogus-reference"); throw new Error("expected confirmFastspringOrder to fail with invalid_scope"); } catch (error) { return assertOperationError(error, { @@ -189,7 +189,7 @@ await run("payment fastspring confirm requires restricted scope", async () => { await run("payment fastspring confirm bogus reference currently yields generic 500", async () => { try { - await ownerClient.payment.confirmFastspringOrder("codex-bogus-reference"); + await ownerClient.payment.confirmFastspringOrder("putio-typescript-sdk-bogus-reference"); throw new Error("expected bogus fastspring reference to fail"); } catch (error) { return assertErrorTag(error, { @@ -234,7 +234,7 @@ await run("payment preview invalid coupon yields typed 404", async () => { try { await ownerClient.payment.changePlan.preview({ - coupon_code: "codex_invalid_coupon", + coupon_code: "putio-typescript-sdk-invalid-coupon", payment_type: "credit-card", plan_path: "1TB_365_once", }); @@ -255,7 +255,7 @@ await run("payment preview invalid plan yields typed 404", async () => { try { await ownerClient.payment.changePlan.preview({ payment_type: "credit-card", - plan_path: "codex_invalid_plan_path", + plan_path: "putio-typescript-sdk-invalid-plan-path", }); throw new Error("expected previewChangePlan to fail"); } catch (error) { @@ -274,7 +274,7 @@ await run("payment submit invalid plan yields typed 404", async () => { try { await ownerClient.payment.changePlan.submit({ payment_type: "credit-card", - plan_path: "codex_invalid_plan_path", + plan_path: "putio-typescript-sdk-invalid-plan-path", }); throw new Error("expected submitChangePlan to fail"); } catch (error) { @@ -291,7 +291,7 @@ await run("payment voucher info invalid code yields typed 404", async () => { await getOwnerPaymentInfo(); try { - await ownerClient.payment.voucher.getInfo("codex-invalid-voucher"); + await ownerClient.payment.voucher.getInfo("putio-typescript-sdk-invalid-voucher"); throw new Error("expected getVoucherInfo to fail"); } catch (error) { return assertOperationError(error, { @@ -307,7 +307,7 @@ await run("payment redeem invalid code yields typed 404", async () => { await getOwnerPaymentInfo(); try { - await ownerClient.payment.voucher.redeem("codex-invalid-voucher"); + await ownerClient.payment.voucher.redeem("putio-typescript-sdk-invalid-voucher"); throw new Error("expected redeemVoucher to fail"); } catch (error) { return assertOperationError(error, { @@ -367,7 +367,7 @@ await run("payment opennode unknown plan yields typed 400", async () => { await getOwnerPaymentInfo(); try { - await ownerClient.payment.methods.createOpenNodeCharge("codex-invalid-plan"); + await ownerClient.payment.methods.createOpenNodeCharge("putio-typescript-sdk-invalid-plan"); throw new Error("expected createOpenNodeCharge to fail"); } catch (error) { return assertOperationError(error, { @@ -422,7 +422,7 @@ await run("payment sub-account voucher redeem is rejected", async () => { const subAccountClient = await getSubAccountClient(); try { - await subAccountClient.payment.voucher.redeem("codex-invalid-voucher"); + await subAccountClient.payment.voucher.redeem("putio-typescript-sdk-invalid-voucher"); throw new Error("expected redeemVoucher to reject sub-account fixture"); } catch (error) { return assertPaymentSubAccountRestriction(error, "redeemVoucher"); diff --git a/test/live/domains/rss.ts b/test/live/domains/rss.ts index b7e5cf4..4c98266 100644 --- a/test/live/domains/rss.ts +++ b/test/live/domains/rss.ts @@ -71,7 +71,7 @@ await run("rss create update lifecycle", async () => { dont_process_whole_feed: true, keyword: "", parent_dir_id: 0, - title: `codex sdk rss ${seed}`, + title: `putio-typescript-sdk rss ${seed}`, unwanted_keywords: "", }); @@ -84,7 +84,7 @@ await run("rss create update lifecycle", async () => { const fetched = await authClient.rss.get(created.id); assert(fetched.id === created.id, "expected fetched feed id"); - const updatedTitle = `codex sdk rss updated ${seed}`; + const updatedTitle = `putio-typescript-sdk rss updated ${seed}`; await authClient.rss.update(created.id, { delete_old_files: true, @@ -138,7 +138,7 @@ await run("rss create update lifecycle", async () => { await run("rss create is writable with oauth token", async () => { const createdProbe = await createProbeRssFeed(oauthClient, { - title: `codex oauth rss ${Date.now()}`, + title: `putio-typescript-sdk oauth rss ${Date.now()}`, }); const created = createdProbe.feed; @@ -155,7 +155,7 @@ await run("rss invalid url yields typed error", async () => { try { await authClient.rss.create({ rss_source_url: "not-a-url", - title: "codex invalid rss", + title: "putio-typescript-sdk invalid rss", }); throw new Error("expected invalid URL to fail"); } catch (error) { @@ -291,7 +291,7 @@ await run("rss missing update feed yields typed not found", async () => { try { await authClient.rss.update(2147483647, { rss_source_url: rssSourceUrl, - title: "codex missing rss", + title: "putio-typescript-sdk missing rss", }); throw new Error("expected missing update feed to fail"); } catch (error) { diff --git a/test/live/domains/sharing.ts b/test/live/domains/sharing.ts index 886d968..d8477b4 100644 --- a/test/live/domains/sharing.ts +++ b/test/live/domains/sharing.ts @@ -32,7 +32,7 @@ const cleanupOwnedProbeFiles = async (ids: readonly number[]): Promise => }; const createOwnedProbeFile = async (): Promise => { - const name = `codex_sdk_public_share_probe_${Date.now()}.txt`; + const name = `putio-typescript-sdk-public-share-probe-${Date.now()}.txt`; const upload = await authClient.files.upload({ file: new File(["sdk public share probe\n"], name, { type: "text/plain", @@ -100,7 +100,7 @@ await run("sharing list shared files shape", async () => { await run("sharing everyone lifecycle", async () => { const seed = Date.now(); const folder = await authClient.files.createFolder({ - name: `codex_sdk_sharing_everyone_${seed}`, + name: `putio-typescript-sdk-sharing-everyone-${seed}`, parent_id: 0, }); @@ -138,7 +138,7 @@ await run("sharing specific-friend lifecycle", async () => { const seed = Date.now(); const folder = await authClient.files.createFolder({ - name: `codex_sdk_sharing_friend_${seed}`, + name: `putio-typescript-sdk-sharing-friend-${seed}`, parent_id: 0, }); @@ -191,11 +191,11 @@ await run("sharing child of shared parent yields typed already shared", async () const seed = Date.now(); const parent = await authClient.files.createFolder({ - name: `codex_sdk_sharing_parent_${seed}`, + name: `putio-typescript-sdk-sharing-parent-${seed}`, parent_id: 0, }); const child = await authClient.files.createFolder({ - name: `codex_sdk_sharing_child_${seed}`, + name: `putio-typescript-sdk-sharing-child-${seed}`, parent_id: parent.id, }); @@ -347,17 +347,17 @@ await run("sharing public share access without token yields configuration error" await run("sharing public share pagination mirrors backend contract", async () => { const seed = Date.now(); const folder = await authClient.files.createFolder({ - name: `codex_sdk_public_share_pagination_${seed}`, + name: `putio-typescript-sdk-public-share-pagination-${seed}`, parent_id: 0, }); try { const childA = await authClient.files.createFolder({ - name: `codex_sdk_public_share_child_a_${seed}`, + name: `putio-typescript-sdk-public-share-child-a-${seed}`, parent_id: folder.id, }); const childB = await authClient.files.createFolder({ - name: `codex_sdk_public_share_child_b_${seed}`, + name: `putio-typescript-sdk-public-share-child-b-${seed}`, parent_id: folder.id, }); @@ -498,7 +498,7 @@ await run("sharing clone lifecycle", async () => { ); const destination = await authClient.files.createFolder({ - name: `codex_sdk_clone_target_${Date.now()}`, + name: `putio-typescript-sdk-clone-target-${Date.now()}`, parent_id: 0, }); diff --git a/test/live/domains/transfers.ts b/test/live/domains/transfers.ts index f8e4745..0e6921d 100644 --- a/test/live/domains/transfers.ts +++ b/test/live/domains/transfers.ts @@ -1,16 +1,13 @@ import { createClients, createLiveHarness } from "../support/harness.js"; +import { createTorrentFile } from "../support/binary-fixtures.ts"; -const { client } = await createClients({ +const { authClient, client } = await createClients({ + authClient: "PUTIO_TOKEN_FIRST_PARTY", client: "PUTIO_TOKEN_THIRD_PARTY", }); const live = createLiveHarness("transfers live"); -const { assert, assertOperationError, finish, run, sleep } = live; - -void assertOperationError; -void sleep; - -const SLOW_TRANSFER_PROBE_URL = "https://speed.hetzner.de/100MB.bin"; +const { assert, assertErrorTag, assertOperationError, finish, run, sleep } = live; const waitForTransferError = async (id: number) => { for (let attempt = 0; attempt < 8; attempt += 1) { @@ -26,6 +23,20 @@ const waitForTransferError = async (id: number) => { throw new Error("timed out waiting for transfer to reach ERROR"); }; +const waitForTorrentTransfer = async (id: number) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const transfer = await authClient.transfers.get(id); + + if (transfer.type === "TORRENT") { + return transfer; + } + + await sleep(500); + } + + throw new Error("timed out waiting for uploaded transfer to decode as TORRENT"); +}; + await run("transfers list shape", async () => { const result = await client.transfers.list({ per_page: 5, @@ -75,32 +86,25 @@ await run("transfers info external analysis", async () => { await run("transfers addMany envelope shape", async () => { const result = await client.transfers.addMany([ { - url: `https://example.invalid/codex-transfer-${Date.now()}.iso`, + url: `https://example.invalid/putio-typescript-sdk-transfer-${Date.now()}.iso`, }, { - url: "", + url: "not-a-url", }, ]); - assert(Array.isArray(result.transfers), "expected transfers array"); - assert(Array.isArray(result.errors), "expected per-item errors array"); - assert(result.transfers.length >= 1, "expected at least one created transfer"); - - const firstTransferId = result.transfers[0]?.id; - const firstError = result.errors[0]; - - if (typeof firstTransferId === "number") { - await client.transfers.cancel([firstTransferId]).catch(() => undefined); - await client.transfers.clean([firstTransferId]).catch(() => undefined); + try { + assert(Array.isArray(result.transfers), "expected transfers array"); + assert(Array.isArray(result.errors), "expected per-item errors array"); + assert(result.transfers.length >= 1, "expected at least one created transfer"); + assert(result.errors.length === 1, "expected one invalid URL error"); + } finally { + for (const transfer of result.transfers) { + await client.transfers.cancel([transfer.id]).catch(() => undefined); + await client.transfers.remove({ ids: [transfer.id] }).catch(() => undefined); + await client.transfers.clean([transfer.id]).catch(() => undefined); + } } - - return { - behaves_like_partial: result.errors.length > 0, - first_error_type: firstError?.error_type ?? null, - first_transfer_id: firstTransferId ?? null, - transfer_count: result.transfers.length, - error_count: result.errors.length, - }; }); await run("empty transfer url yields typed error", async () => { @@ -110,12 +114,7 @@ await run("empty transfer url yields typed error", async () => { }); throw new Error("expected empty transfer url to fail"); } catch (error) { - return assertOperationError(error, { - domain: "transfers", - errorType: "EMPTY_URL", - operation: "add", - statusCode: 400, - }); + return assertErrorTag(error, { tag: "PutioValidationError" }); } }); @@ -147,7 +146,7 @@ await run("missing transfer stop recording yields typed error", async () => { await run("non-live transfer stop recording yields typed not-recording", async () => { const created = await client.transfers.add({ - url: `https://example.invalid/codex-transfer-${Date.now()}.iso`, + url: `https://example.invalid/putio-typescript-sdk-transfer-${Date.now()}.iso`, }); try { @@ -190,7 +189,7 @@ await run("reannounce currently rejects with typed bad request", async () => { await run("non-torrent reannounce currently falls back to typed bad request", async () => { const created = await client.transfers.add({ - url: `https://example.invalid/codex-transfer-${Date.now()}.iso`, + url: `https://example.invalid/putio-typescript-sdk-transfer-${Date.now()}.iso`, }); try { @@ -209,39 +208,11 @@ await run("non-torrent reannounce currently falls back to typed bad request", as } }); -await run("retry on fresh non-error transfer returns transfer shape", async () => { - const created = await client.transfers.add({ - url: SLOW_TRANSFER_PROBE_URL, - }); - - try { - const current = await client.transfers.get(created.id); - - if (current.status === "ERROR") { - throw new Error("fresh transfer reached ERROR before the non-error retry branch was checked"); - } - - const retried = await client.transfers.retry(created.id); - - assert(retried.id === created.id, "expected retry to return same transfer"); - assert(retried.status !== "ERROR", "expected retried transfer to remain non-error"); - - return { - initial_status: current.status, - retried_status: retried.status, - }; - } finally { - await client.transfers.cancel([created.id]).catch(() => undefined); - await client.transfers.clean([created.id]).catch(() => undefined); - } -}); - -await run("transfers disposable lifecycle", async () => { - const countBefore = await client.transfers.count(); - +await run("failed URL transfer can be retried and cancelled", async () => { const created = await client.transfers.add({ - url: `https://example.invalid/codex-transfer-${Date.now()}.iso`, + url: `https://example.invalid/putio-typescript-sdk-transfer-${Date.now()}.iso`, }); + const transferIds = new Set([created.id]); try { assert(typeof created.id === "number", "expected created transfer id"); @@ -259,22 +230,50 @@ await run("transfers disposable lifecycle", async () => { const errored = await waitForTransferError(created.id); assert(typeof errored.error_message === "string", "expected transfer error message"); - const fetched = await client.transfers.get(created.id); - assert(fetched.id === created.id, "expected get to return created transfer"); + const retried = await client.transfers.retry(created.id); + transferIds.add(retried.id); + assert(retried.status !== "ERROR", "expected retry to leave terminal error state"); + await waitForTransferError(retried.id); + + const fetched = await client.transfers.get(retried.id); + assert(fetched.id === retried.id, "expected get to return retried transfer"); + + await client.transfers.cancel([retried.id]); + } finally { + for (const id of transferIds) { + await client.transfers.cancel([id]).catch(() => undefined); + await client.transfers.clean([id]).catch(() => undefined); + } + } +}); - await client.transfers.cancel([created.id]); +await run("torrent upload returns a decoded transfer and metainfo", async () => { + const name = `putio-typescript-sdk-torrent-transfer-${Date.now()}`; + const upload = await authClient.files.upload({ + file: createTorrentFile(name), + fileName: `${name}.torrent`, + parentId: 0, + }); + + if (upload.type === "file") { + try { + throw new Error("expected torrent upload to return a transfer"); + } finally { + await authClient.files.delete([upload.file.id], { skipTrash: true }); + } + } - const countAfter = await client.transfers.count(); + const transferId = upload.transfer.id; - return { - count_after_cancel: countAfter, - count_before: countBefore, - created_id: created.id, - final_status: errored.status, - }; + try { + await waitForTorrentTransfer(transferId); + const torrent = await authClient.transfers.getTorrent(transferId); + assert(torrent.byteLength > 20, "expected torrent metainfo bytes"); + assert(torrent[0] === 0x64, "expected bencoded torrent metainfo"); } finally { - await client.transfers.cancel([created.id]).catch(() => undefined); - await client.transfers.clean([created.id]).catch(() => undefined); + await authClient.transfers.cancel([transferId]).catch(() => undefined); + await authClient.transfers.remove({ ids: [transferId] }).catch(() => undefined); + await authClient.transfers.clean([transferId]).catch(() => undefined); } }); diff --git a/test/live/domains/trash.ts b/test/live/domains/trash.ts index 66b5416..7a31506 100644 --- a/test/live/domains/trash.ts +++ b/test/live/domains/trash.ts @@ -124,7 +124,7 @@ await run("trash list shape", async () => { await run("trash continue invalid cursor yields typed 400", async () => { try { - await authClient.trash.continue("codex-invalid-trash-cursor", { + await authClient.trash.continue("putio-typescript-sdk-invalid-trash-cursor", { per_page: 1, }); throw new Error("expected invalid trash cursor to fail"); @@ -164,7 +164,7 @@ await run("trash disposable lifecycle", () => try { for (const suffix of ["a", "b", "c"]) { const folder = await authClient.files.createFolder({ - name: `codex_sdk_trash_${seed}_${suffix}`, + name: `putio-typescript-sdk-trash-${seed}-${suffix}`, parent_id: 0, }); @@ -265,7 +265,7 @@ await run("trash bulk restore restores multiple top-level entries", () => try { for (const suffix of ["bulk_a", "bulk_b"]) { const folder = await authClient.files.createFolder({ - name: `codex_sdk_trash_${seed}_${suffix}`, + name: `putio-typescript-sdk-trash-${seed}-${suffix}`, parent_id: 0, }); @@ -319,13 +319,13 @@ await run("trash child restore and delete reject non-toplevel entries", () => try { const parent = await authClient.files.createFolder({ - name: `codex_sdk_trash_${seed}_parent`, + name: `putio-typescript-sdk-trash-${seed}-parent`, parent_id: 0, }); parentId = parent.id; const child = await authClient.files.createFolder({ - name: `codex_sdk_trash_${seed}_child`, + name: `putio-typescript-sdk-trash-${seed}-child`, parent_id: parent.id, }); childId = child.id; diff --git a/test/live/domains/zips.ts b/test/live/domains/zips.ts index 8dd5f1d..c9b4840 100644 --- a/test/live/domains/zips.ts +++ b/test/live/domains/zips.ts @@ -49,7 +49,7 @@ const createCursorProbeFiles = async () => { const ids: number[] = []; for (let index = 0; index < 6; index += 1) { - const name = `codex_sdk_zip_cursor_${Date.now()}_${index}.txt`; + const name = `putio-typescript-sdk-zip-cursor-${Date.now()}-${index}.txt`; const upload = await authClient.files.upload({ file: new File([`sdk zip cursor probe ${index}\n`], name, { type: "text/plain", diff --git a/test/live/oauth.test.ts b/test/live/oauth.test.ts index fc3a202..9cbaad2 100644 --- a/test/live/oauth.test.ts +++ b/test/live/oauth.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "vite-plus/test"; import { createLiveTokenClients } from "./support/helpers.js"; -const DISPOSABLE_CALLBACK = "https://example.com/codex-sdk-live/oauth/callback"; -const DISPOSABLE_WEBSITE = "https://example.com/codex-sdk-live/oauth"; +const DISPOSABLE_CALLBACK = "https://example.com/putio-typescript-sdk-live/oauth/callback"; +const DISPOSABLE_WEBSITE = "https://example.com/putio-typescript-sdk-live/oauth"; const DISPOSABLE_ICON_BYTES = Uint8Array.from( Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlAb9sAAAAASUVORK5CYII=", @@ -61,9 +61,9 @@ describe.sequential("oauth live", () => { try { const created = await authClient.oauth.create({ callback: DISPOSABLE_CALLBACK, - description: `codex oauth disposable ${seed}`, + description: `putio-typescript-sdk oauth disposable ${seed}`, hidden: true, - name: `Codex SDK Disposable ${seed}`, + name: `put.io TypeScript SDK Disposable ${seed}`, website: DISPOSABLE_WEBSITE, }); @@ -76,7 +76,7 @@ describe.sequential("oauth live", () => { const updated = await authClient.oauth.update({ callback: `${DISPOSABLE_CALLBACK}?updated=${seed}`, - description: `codex oauth disposable updated ${seed}`, + description: `putio-typescript-sdk oauth disposable updated ${seed}`, hidden: false, id: created.app.id, website: `${DISPOSABLE_WEBSITE}?updated=${seed}`, diff --git a/test/live/support/binary-fixtures.ts b/test/live/support/binary-fixtures.ts new file mode 100644 index 0000000..8b3607a --- /dev/null +++ b/test/live/support/binary-fixtures.ts @@ -0,0 +1,91 @@ +import { createHash } from "node:crypto"; + +const encoder = new TextEncoder(); + +const concatBytes = (...parts: ReadonlyArray): Uint8Array => { + const result = new Uint8Array(parts.reduce((length, part) => length + part.byteLength, 0)); + let offset = 0; + + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + + return result; +}; + +const encodeText = (value: string): Uint8Array => encoder.encode(value); + +const crc32 = (value: Uint8Array): number => { + let crc = 0xffffffff; + + for (const byte of value) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + + return (crc ^ 0xffffffff) >>> 0; +}; + +const zipHeader = (length: number, write: (view: DataView) => void): Uint8Array => { + const bytes = new Uint8Array(length); + write(new DataView(bytes.buffer)); + return bytes; +}; + +export const createStoredZipFile = (name: string): File => { + const entryName = encodeText("fixture.txt"); + const contents = encodeText("put.io SDK live archive fixture\n"); + const checksum = crc32(contents); + const localHeader = zipHeader(30, (view) => { + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint32(14, checksum, true); + view.setUint32(18, contents.byteLength, true); + view.setUint32(22, contents.byteLength, true); + view.setUint16(26, entryName.byteLength, true); + }); + const centralHeader = zipHeader(46, (view) => { + view.setUint32(0, 0x02014b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 20, true); + view.setUint32(16, checksum, true); + view.setUint32(20, contents.byteLength, true); + view.setUint32(24, contents.byteLength, true); + view.setUint16(28, entryName.byteLength, true); + }); + const centralOffset = localHeader.byteLength + entryName.byteLength + contents.byteLength; + const centralSize = centralHeader.byteLength + entryName.byteLength; + const end = zipHeader(22, (view) => { + view.setUint32(0, 0x06054b50, true); + view.setUint16(8, 1, true); + view.setUint16(10, 1, true); + view.setUint32(12, centralSize, true); + view.setUint32(16, centralOffset, true); + }); + const archive = concatBytes(localHeader, entryName, contents, centralHeader, entryName, end); + + return new File([archive], name, { type: "application/zip" }); +}; + +export const createTorrentFile = (name: string): File => { + const payload = encodeText("put.io SDK live torrent fixture\n"); + const pieceHash = new Uint8Array(createHash("sha1").update(payload).digest()); + const payloadName = `${name}.txt`; + const info = concatBytes( + encodeText(`d6:lengthi${payload.byteLength}e4:name${payloadName.length}:${payloadName}`), + encodeText(`12:piece lengthi16384e6:pieces20:`), + pieceHash, + encodeText("e"), + ); + const announce = `https://example.invalid/${name}`; + const torrent = concatBytes( + encodeText(`d8:announce${announce.length}:${announce}4:info`), + info, + encodeText("e"), + ); + + return new File([torrent], `${name}.torrent`, { type: "application/x-bittorrent" }); +}; diff --git a/test/live/support/bootstrap.ts b/test/live/support/bootstrap.ts index 879bd55..02bf5cd 100644 --- a/test/live/support/bootstrap.ts +++ b/test/live/support/bootstrap.ts @@ -49,9 +49,9 @@ export type BootstrappedTokens = { }; }; -const THIRD_PARTY_BOOTSTRAP_APP_NAME = "Codex SDK Live App"; -const THIRD_PARTY_BOOTSTRAP_CALLBACK = "https://example.com/codex-sdk-live/callback"; -const THIRD_PARTY_BOOTSTRAP_WEBSITE = "https://example.com/codex-sdk-live"; +const THIRD_PARTY_BOOTSTRAP_APP_NAME = "put.io TypeScript SDK Live App"; +const THIRD_PARTY_BOOTSTRAP_CALLBACK = "https://example.com/putio-typescript-sdk-live/callback"; +const THIRD_PARTY_BOOTSTRAP_WEBSITE = "https://example.com/putio-typescript-sdk-live"; const TOTP_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/test/live/support/friends.ts b/test/live/support/friends.ts index 874c3c9..031facb 100644 --- a/test/live/support/friends.ts +++ b/test/live/support/friends.ts @@ -83,8 +83,8 @@ type SecondaryClientFactory = (config?: { readonly accessToken?: string; }) => Promise; -const SHARED_FRIEND_FIXTURE_FOLDER_NAME = "codex_sdk_shared_friend_fixture"; -const SHARED_FRIEND_FIXTURE_FILE_NAME = "codex_sdk_shared_friend_fixture.txt"; +const SHARED_FRIEND_FIXTURE_FOLDER_NAME = "putio-typescript-sdk-shared-friend-fixture"; +const SHARED_FRIEND_FIXTURE_FILE_NAME = "putio-typescript-sdk-shared-friend-fixture.txt"; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/test/live/support/media.ts b/test/live/support/media.ts index 147f53c..3d87595 100644 --- a/test/live/support/media.ts +++ b/test/live/support/media.ts @@ -9,7 +9,7 @@ const SAFE_OWNED_VIDEO_FIXTURE_NAMES = new Set([ "Mario1_507_HQ_512kb.mp4", "Sintel.mp4", ]); -const SAFE_OWNED_VIDEO_FIXTURE_PREFIXES = ["codex_sdk_", "codex-sdk-"]; +const SAFE_OWNED_VIDEO_FIXTURE_PREFIXES = ["putio-typescript-sdk-"]; const isSafeOwnedVideoFixtureName = (name: string): boolean => SAFE_OWNED_VIDEO_FIXTURE_NAMES.has(name) || @@ -98,6 +98,6 @@ export const requireOwnedVideoFixture = async ( } throw new Error( - "Missing safe owned MP4 fixture. Set PUTIO_LIVE_OWNED_VIDEO_FILE_ID or upload an unshared codex_sdk_*/codex-sdk-* MP4 fixture.", + "Missing safe owned MP4 fixture. Set PUTIO_LIVE_OWNED_VIDEO_FILE_ID or upload an unshared putio-typescript-sdk-* MP4 fixture.", ); }; diff --git a/test/live/support/secrets.test.ts b/test/live/support/secrets.test.ts index 1d94c1f..a2f51b6 100644 --- a/test/live/support/secrets.test.ts +++ b/test/live/support/secrets.test.ts @@ -7,29 +7,34 @@ import { describe, expect, it } from "vite-plus/test"; import { loadEnvFiles } from "./secrets.ts"; describe("live secret env loading", () => { - it("keeps process env first, then .env.local, then .env", () => { + it("keeps process env first, then token cache, .env.local, and .env", () => { const dir = mkdtempSync(join(tmpdir(), "putio-sdk-env-")); + const cachePath = join(dir, ".env.live-tokens"); const localPath = join(dir, ".env.local"); const envPath = join(dir, ".env"); const directKey = "PUTIO_SDK_TEST_DIRECT_PRECEDENCE"; + const cacheKey = "PUTIO_SDK_TEST_CACHE_PRECEDENCE"; const localKey = "PUTIO_SDK_TEST_LOCAL_PRECEDENCE"; const envKey = "PUTIO_SDK_TEST_ENV_FALLBACK"; const originalValues = new Map( - [directKey, localKey, envKey].map((key) => [key, process.env[key]]), + [directKey, cacheKey, localKey, envKey].map((key) => [key, process.env[key]]), ); try { process.env[directKey] = "direct"; + delete process.env[cacheKey]; delete process.env[localKey]; delete process.env[envKey]; + writeFileSync(cachePath, `${directKey}=cache\n${cacheKey}=cache\n`); writeFileSync(localPath, `${directKey}=local\n${localKey}=local\n`); - writeFileSync(envPath, `${directKey}=env\n${localKey}=env\n${envKey}=env\n`); + writeFileSync(envPath, `${directKey}=env\n${cacheKey}=env\n${localKey}=env\n${envKey}=env\n`); - loadEnvFiles([localPath, envPath]); + loadEnvFiles([cachePath, localPath, envPath]); expect(process.env[directKey]).toBe("direct"); + expect(process.env[cacheKey]).toBe("cache"); expect(process.env[localKey]).toBe("local"); expect(process.env[envKey]).toBe("env"); } finally { diff --git a/test/live/support/secrets.ts b/test/live/support/secrets.ts index 8aa4273..ba35420 100644 --- a/test/live/support/secrets.ts +++ b/test/live/support/secrets.ts @@ -65,7 +65,11 @@ const loadPackageEnvFile = (): void => { packageEnvLoaded = true; const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "../../.."); - loadEnvFiles([join(packageRoot, ".env.local"), join(packageRoot, ".env")]); + loadEnvFiles([ + join(packageRoot, ".env.live-tokens"), + join(packageRoot, ".env.local"), + join(packageRoot, ".env"), + ]); }; export const requireSecret = (key: TKey): string => { @@ -146,15 +150,23 @@ export const readFirstPartyClientCredentials = (): PutioClientCredentials => ({ }); export const hydrateLiveTokenEnv = (): void => { + loadPackageEnvFile(); + if (process.env.PUTIO_TOKEN_FIRST_PARTY && process.env.PUTIO_TOKEN_THIRD_PARTY) { return; } if (!process.env.PUTIO_TOKEN_FIRST_PARTY) { - process.env.PUTIO_TOKEN_FIRST_PARTY = readOptionalSecret("PUTIO_AUTH_TOKEN"); + const legacyToken = readOptionalSecret("PUTIO_AUTH_TOKEN"); + if (legacyToken) { + process.env.PUTIO_TOKEN_FIRST_PARTY = legacyToken; + } } if (!process.env.PUTIO_TOKEN_THIRD_PARTY) { - process.env.PUTIO_TOKEN_THIRD_PARTY = readOptionalSecret("PUTIO_OAUTH_TOKEN"); + const legacyToken = readOptionalSecret("PUTIO_OAUTH_TOKEN"); + if (legacyToken) { + process.env.PUTIO_TOKEN_THIRD_PARTY = legacyToken; + } } };