From f30776bff50f173b12dfcf967bd10538029dec6d Mon Sep 17 00:00:00 2001 From: Goal Champion #873 Date: Mon, 16 Mar 2026 18:39:54 +0000 Subject: [PATCH 1/9] feat(tests): replace nock with MSW mock server infrastructure Migrate all unit tests from nock-based HTTP interception to a proper MSW (Mock Service Worker) mock server using msw/node + @mswjs/interceptors. Changes: - Add msw@2 as devDependency, remove nock - Create tests/mocks/server.ts: MSW server with documentation on adding handlers - Update tests/setup.js: start/stop/reset MSW server in beforeAll/afterEach/afterAll - Port all 7 nock-based test files to per-test server.use() handlers: entities, auth, functions, connectors, integrations, custom-integrations, client - Replace vi.stubGlobal("fetch", ...) in functions.test.ts with MSW handlers - Add request capture pattern for asserting headers (Authorization, Base44-State) - Use RegExp patterns for MSW handlers where operationId contains URL-unsafe chars All 116 unit tests pass (npm run test:unit exits 0). Goal: https://github.com/base44-dev/gremlins/issues/873 Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 527 ++++++++++++++++++++++++ package.json | 2 +- tests/mocks/server.ts | 47 +++ tests/setup.js | 16 +- tests/unit/auth.test.js | 543 ++++++++----------------- tests/unit/client.test.js | 401 ++++++++---------- tests/unit/connectors.test.ts | 86 ++-- tests/unit/custom-integrations.test.ts | 345 ++++++---------- tests/unit/entities.test.ts | 234 +++-------- tests/unit/functions.test.ts | 541 ++++++++---------------- tests/unit/integrations.test.js | 125 +++--- 11 files changed, 1374 insertions(+), 1493 deletions(-) create mode 100644 tests/mocks/server.ts diff --git a/package-lock.json b/package-lock.json index 0df9e92a..acee5ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "dotenv": "^16.3.1", "eslint": "^9.39.2", "eslint-plugin-import": "^2.32.0", + "msw": "^2.12.11", "nock": "^13.4.0", "typedoc": "^0.28.14", "typedoc-plugin-markdown": "^4.9.0", @@ -901,6 +902,94 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -974,6 +1063,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1012,6 +1119,31 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1437,6 +1569,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1943,6 +2082,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2355,6 +2504,49 @@ "node": "*" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2408,6 +2600,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2616,6 +2822,13 @@ "dev": true, "license": "ISC" }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/engine.io-client": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", @@ -3442,6 +3655,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -3597,6 +3820,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3688,6 +3921,13 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3937,6 +4177,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -3996,6 +4246,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4637,6 +4894,61 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.12.11", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.11.tgz", + "integrity": "sha512-dVg20zi2I2EvnwH/+WupzsOC2mCa7qsIhyMAWtfRikn6RKtwL9+7SaF1IQ5LyZry4tlUtf6KyTVhnlQiZXozTQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -4855,6 +5167,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -4955,6 +5274,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -5196,6 +5522,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5227,6 +5563,13 @@ "node": ">=4" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5600,6 +5943,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -5621,6 +5974,28 @@ "node": ">= 0.4" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -5680,6 +6055,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -5762,6 +6150,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -5852,6 +6253,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.26" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5875,6 +6296,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -5937,6 +6371,22 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -6156,6 +6606,16 @@ "dev": true, "license": "MIT" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6491,6 +6951,21 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6527,6 +7002,16 @@ "node": ">=0.4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6550,6 +7035,35 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6562,6 +7076,19 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 4c2c3f0a..b1cba560 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "dotenv": "^16.3.1", "eslint": "^9.39.2", "eslint-plugin-import": "^2.32.0", - "nock": "^13.4.0", + "msw": "^2.12.11", "typedoc": "^0.28.14", "typedoc-plugin-markdown": "^4.9.0", "typescript": "^5.3.2", diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts new file mode 100644 index 00000000..c37bdd2d --- /dev/null +++ b/tests/mocks/server.ts @@ -0,0 +1,47 @@ +/** + * MSW (Mock Service Worker) server for unit tests. + * + * ## How to add new handlers + * + * Call `server.use()` inside a test to register per-test handlers. + * They are automatically removed after each test by the global `afterEach` + * in `tests/setup.js` (via `server.resetHandlers()`). + * + * ```ts + * import { http, HttpResponse } from 'msw'; + * import { server } from '../mocks/server'; + * + * test('my test', async () => { + * server.use( + * http.get('https://api.base44.com/api/apps/test-app-id/entities/Todo', () => + * HttpResponse.json([{ id: '1', title: 'Test' }]) + * ) + * ); + * // ... test code + * }); + * ``` + * + * ## Architecture + * + * ``` + * Vitest test → SDK (axios / fetch) → MSW Node server → handler → fake response + * ``` + * + * MSW intercepts requests at the Node.js http layer (`@mswjs/interceptors`) + * and also intercepts native `fetch` calls. No axios mocking or `vi.stubGlobal` + * needed. + * + * ## Modules and their base URL patterns + * + * | Module | Base path | + * |--------------|------------------------------------------------------------------| + * | entities | `/api/apps/:appId/entities/:entityName` | + * | auth | `/api/apps/:appId/entities/User/me`, `/api/apps/:appId/auth/...` | + * | functions | `/api/apps/:appId/functions/:name`, `/api/functions/:name` | + * | integrations | `/api/apps/:appId/integration-endpoints/:pkg/:endpoint` | + * | custom-int | `/api/apps/:appId/integrations/custom/:slug/:operationId` | + * | connectors | `/api/apps/:appId/external-auth/tokens/:type` | + */ +import { setupServer } from 'msw/node'; + +export const server = setupServer(); diff --git a/tests/setup.js b/tests/setup.js index 901e0f24..93828565 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,7 +1,8 @@ // Load environment variables from .env file import dotenv from 'dotenv'; import './utils/circular-json-handler.js'; -import { beforeAll, afterAll, test } from 'vitest'; +import { beforeAll, afterAll, afterEach } from 'vitest'; +import { server } from './mocks/server.ts'; try { dotenv.config({ path: './tests/.env' }); @@ -16,13 +17,18 @@ try { console.warn('Failed to load circular JSON handler:', err.message); } -// Global beforeAll and afterAll hooks +// MSW server lifecycle beforeAll(() => { + server.listen({ onUnhandledRequest: 'warn' }); console.log('Starting Base44 SDK tests...'); - // Add any global setup here +}); + +afterEach(() => { + // Remove per-test handlers registered via server.use() + server.resetHandlers(); }); afterAll(() => { + server.close(); console.log('Completed Base44 SDK tests'); - // Add any global teardown here -}); \ No newline at end of file +}); diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 81aa9aac..30a5a0fc 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -1,13 +1,15 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; -import nock from 'nock'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; import { createClient } from '../../src/index.ts'; describe('Auth Module', () => { let base44; - let scope; const appId = 'test-app-id'; const serverUrl = 'https://api.base44.com'; const appBaseUrl = 'https://api.base44.com'; + const meUrl = `${serverUrl}/api/apps/${appId}/entities/User/me`; + const loginUrl = `${serverUrl}/api/apps/${appId}/auth/login`; beforeEach(() => { // Mock window.addEventListener and document for analytics module @@ -24,36 +26,18 @@ describe('Auth Module', () => { }; } - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - appBaseUrl, - }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on('no match', (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log('Headers:', req.getHeaders()); - }); + base44 = createClient({ serverUrl, appId, appBaseUrl }); }); - + afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners('no match'); - nock.enableNetConnect(); - + base44.cleanup(); + // Clean up localStorage if it exists if (typeof window !== 'undefined' && window.localStorage) { window.localStorage.clear(); } }); - + describe('me()', () => { test('should fetch current user information', async () => { const mockUser = { @@ -62,219 +46,143 @@ describe('Auth Module', () => { name: 'Test User', role: 'user' }; - - // Mock the API response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(200, mockUser); - - // Call the API + + server.use(http.get(meUrl, () => HttpResponse.json(mockUser))); + const result = await base44.auth.me(); - - // Verify the response - auth methods return data directly, not wrapped + expect(result).toEqual(mockUser); expect(result.id).toBe('user-123'); expect(result.email).toBe('test@example.com'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('should handle authentication errors', async () => { - // Mock the API error response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(401, { detail: 'Unauthorized' }); - - // Call the API and expect an error + server.use(http.get(meUrl, () => HttpResponse.json({ detail: 'Unauthorized' }, { status: 401 }))); + await expect(base44.auth.me()).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); }); - + describe('updateMe()', () => { test('should update current user data', async () => { - const updateData = { - name: 'Updated Name', - email: 'updated@example.com' - }; - - const updatedUser = { - id: 'user-123', - ...updateData, - role: 'user' - }; - - // Mock the API response - scope.put(`/api/apps/${appId}/entities/User/me`, updateData) - .reply(200, updatedUser); - - // Call the API + const updateData = { name: 'Updated Name', email: 'updated@example.com' }; + const updatedUser = { id: 'user-123', ...updateData, role: 'user' }; + + server.use(http.put(meUrl, () => HttpResponse.json(updatedUser))); + const result = await base44.auth.updateMe(updateData); - - // Verify the response - auth methods return data directly, not wrapped + expect(result).toEqual(updatedUser); expect(result.name).toBe('Updated Name'); expect(result.email).toBe('updated@example.com'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('should handle validation errors', async () => { - const invalidData = { - email: 'invalid-email' - }; - - // Mock the API error response - scope.put(`/api/apps/${appId}/entities/User/me`, invalidData) - .reply(400, { detail: 'Invalid email format' }); - - // Call the API and expect an error - await expect(base44.auth.updateMe(invalidData)).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + server.use(http.put(meUrl, () => HttpResponse.json({ detail: 'Invalid email format' }, { status: 400 }))); + + await expect(base44.auth.updateMe({ email: 'invalid-email' })).rejects.toThrow(); }); }); - + describe('login()', () => { test('should throw error when not in browser environment', () => { - // Mock window as undefined to simulate non-browser environment const originalWindow = global.window; delete global.window; - + expect(() => { base44.auth.redirectToLogin('/dashboard'); }).toThrow('Login method can only be used in a browser environment'); - - // Restore window + global.window = originalWindow; }); - + test('should redirect to login page with correct URL in browser environment', () => { - // Mock window object const mockLocation = { href: '' }; const originalWindow = global.window; - global.window = { - location: mockLocation - }; + global.window = { location: mockLocation }; const nextUrl = 'https://example.com/dashboard'; base44.auth.redirectToLogin(nextUrl); - // Verify the redirect URL was set correctly expect(mockLocation.href).toBe( `${appBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}` ); - // Restore window global.window = originalWindow; }); - + test('should use current URL when nextUrl is not provided', () => { - // Mock window object const currentUrl = 'https://example.com/current-page'; const mockLocation = { href: currentUrl }; const originalWindow = global.window; - global.window = { - location: mockLocation - }; + global.window = { location: mockLocation }; base44.auth.redirectToLogin(); - // Verify the redirect URL uses current URL expect(mockLocation.href).toBe( `${appBaseUrl}/login?from_url=${encodeURIComponent(currentUrl)}` ); - // Restore window global.window = originalWindow; }); test('should use appBaseUrl for login redirect when provided', () => { const customAppBaseUrl = 'https://custom-app.example.com'; - const clientWithCustomUrl = createClient({ - serverUrl, - appId, - appBaseUrl: customAppBaseUrl, - }); + const clientWithCustomUrl = createClient({ serverUrl, appId, appBaseUrl: customAppBaseUrl }); - // Mock window.location const originalWindow = global.window; const mockLocation = { href: '' }; - global.window = { - location: mockLocation - }; + global.window = { location: mockLocation }; - const nextUrl = 'https://example.com/dashboard'; - clientWithCustomUrl.auth.redirectToLogin(nextUrl); + clientWithCustomUrl.auth.redirectToLogin('https://example.com/dashboard'); - // Verify the redirect URL uses the custom appBaseUrl expect(mockLocation.href).toBe( - `${customAppBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}` + `${customAppBaseUrl}/login?from_url=${encodeURIComponent('https://example.com/dashboard')}` ); - // Restore window global.window = originalWindow; }); test('should use relative URL for login redirect when appBaseUrl is not provided', () => { - // Create a client without appBaseUrl - const clientWithoutAppBaseUrl = createClient({ - serverUrl, - appId, - }); + const clientWithoutAppBaseUrl = createClient({ serverUrl, appId }); - // Mock window.location const originalWindow = global.window; const mockLocation = { href: '', origin: 'https://current-app.com' }; - global.window = { - location: mockLocation - }; + global.window = { location: mockLocation }; - const nextUrl = 'https://example.com/dashboard'; - clientWithoutAppBaseUrl.auth.redirectToLogin(nextUrl); + clientWithoutAppBaseUrl.auth.redirectToLogin('https://example.com/dashboard'); - // Verify the redirect URL uses a relative path (no appBaseUrl prefix) expect(mockLocation.href).toBe( - `/login?from_url=${encodeURIComponent(nextUrl)}` + `/login?from_url=${encodeURIComponent('https://example.com/dashboard')}` ); - // Restore window global.window = originalWindow; }); }); - + describe('logout()', () => { test('should remove token from axios headers', async () => { - // Set a token first base44.auth.setToken('test-token', false); - - // Mock the API response for me() call - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', 'Bearer test-token') - .reply(200, { id: 'user-123', email: 'test@example.com' }); - - // Verify token is set by making a request + + server.use( + http.get(meUrl, ({ request }) => { + if (request.headers.get('Authorization') === 'Bearer test-token') { + return HttpResponse.json({ id: 'user-123', email: 'test@example.com' }); + } + return HttpResponse.json({ detail: 'Unauthorized' }, { status: 401 }); + }) + ); + + // Token is set — should succeed await base44.auth.me(); - expect(scope.isDone()).toBe(true); - - // Call logout + base44.auth.logout(); - - // Mock another me() call to verify no Authorization header is sent - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', (val) => !val) // Should not have Authorization header - .reply(401, { detail: 'Unauthorized' }); - - // Verify no Authorization header is sent after logout (should throw 401) + + // After logout no Authorization header — should fail await expect(base44.auth.me()).rejects.toThrow(); - expect(scope.isDone()).toBe(true); }); - + test('should remove token from localStorage in browser environment', async () => { - // Mock window and localStorage const mockLocalStorage = { removeItem: vi.fn(), getItem: vi.fn(), @@ -284,28 +192,21 @@ describe('Auth Module', () => { const originalWindow = global.window; global.window = { localStorage: mockLocalStorage, - location: { - reload: vi.fn() - } + location: { reload: vi.fn() } }; - - // Set a token to localStorage first + base44.auth.setToken('test-token', true); expect(mockLocalStorage.setItem).toHaveBeenCalledWith('base44_access_token', 'test-token'); - - // Call logout + base44.auth.logout(); - - // Verify token was removed from localStorage + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('base44_access_token'); expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('token'); - - // Restore window + global.window = originalWindow; }); - + test('should handle localStorage errors gracefully', async () => { - // Mock window and localStorage with error const mockLocalStorage = { removeItem: vi.fn().mockImplementation(() => { throw new Error('localStorage error'); @@ -315,79 +216,63 @@ describe('Auth Module', () => { const originalWindow = global.window; global.window = { localStorage: mockLocalStorage, - location: { - reload: vi.fn() - } + location: { reload: vi.fn() } }; - - // Call logout - should not throw + base44.auth.logout(); - - // Verify error was logged + expect(consoleSpy).toHaveBeenCalledWith('Failed to remove token from localStorage:', expect.any(Error)); - - // Restore + consoleSpy.mockRestore(); global.window = originalWindow; }); - + test('should redirect to specified URL after logout', async () => { - // Mock window object const mockLocation = { href: '' }; const originalWindow = global.window; - global.window = { - location: mockLocation - }; + global.window = { location: mockLocation }; const redirectUrl = 'https://example.com/logout-success'; base44.auth.logout(redirectUrl); - // Verify redirect to server-side logout endpoint with from_url parameter const expectedUrl = `${appBaseUrl}/api/apps/auth/logout?from_url=${encodeURIComponent(redirectUrl)}`; expect(mockLocation.href).toBe(expectedUrl); - // Restore window global.window = originalWindow; }); - + test('should redirect to logout endpoint when no redirect URL is provided', async () => { - // Mock window object const mockLocation = { href: 'https://example.com/current-page' }; const originalWindow = global.window; - global.window = { - location: mockLocation - }; + global.window = { location: mockLocation }; - // Call logout without redirect URL base44.auth.logout(); - // Verify redirect to server-side logout endpoint with current page as from_url const expectedUrl = `${appBaseUrl}/api/apps/auth/logout?from_url=${encodeURIComponent('https://example.com/current-page')}`; expect(mockLocation.href).toBe(expectedUrl); - // Restore window global.window = originalWindow; }); }); - + describe('setToken()', () => { test('should set token in axios headers', async () => { const token = 'test-access-token'; - base44.auth.setToken(token, false); - - // Mock the API response for me() call - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', `Bearer ${token}`) - .reply(200, { id: 'user-123', email: 'test@example.com' }); - - // Verify token is set by making a request + + let capturedAuth = null; + server.use( + http.get(meUrl, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ id: 'user-123', email: 'test@example.com' }); + }) + ); + await base44.auth.me(); - expect(scope.isDone()).toBe(true); + expect(capturedAuth).toBe(`Bearer ${token}`); }); - + test('should save token to localStorage when requested', () => { - // Mock window and localStorage const mockLocalStorage = { setItem: vi.fn(), getItem: vi.fn(), @@ -395,22 +280,16 @@ describe('Auth Module', () => { clear: vi.fn() }; const originalWindow = global.window; - global.window = { - localStorage: mockLocalStorage - }; - - const token = 'test-access-token'; - base44.auth.setToken(token, true); - - // Verify token was saved to localStorage - expect(mockLocalStorage.setItem).toHaveBeenCalledWith('base44_access_token', token); - - // Restore window + global.window = { localStorage: mockLocalStorage }; + + base44.auth.setToken('test-access-token', true); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith('base44_access_token', 'test-access-token'); + global.window = originalWindow; }); - + test('should not save token to localStorage when not requested', () => { - // Mock window and localStorage const mockLocalStorage = { setItem: vi.fn(), getItem: vi.fn(), @@ -418,35 +297,31 @@ describe('Auth Module', () => { clear: vi.fn() }; const originalWindow = global.window; - global.window = { - localStorage: mockLocalStorage - }; - - const token = 'test-access-token'; - base44.auth.setToken(token, false); - - // Verify token was not saved to localStorage + global.window = { localStorage: mockLocalStorage }; + + base44.auth.setToken('test-access-token', false); + expect(mockLocalStorage.setItem).not.toHaveBeenCalled(); - - // Restore window + global.window = originalWindow; }); - + test('should handle empty token gracefully', async () => { base44.auth.setToken('', false); - - // Mock the API response for me() call - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', (val) => !val) // Should not have Authorization header - .reply(401, { detail: 'Unauthorized' }); - - // Verify no Authorization header is sent (should throw 401) + + server.use( + http.get(meUrl, ({ request }) => { + if (!request.headers.has('Authorization')) { + return HttpResponse.json({ detail: 'Unauthorized' }, { status: 401 }); + } + return HttpResponse.json({ id: 'user-123' }); + }) + ); + await expect(base44.auth.me()).rejects.toThrow(); - expect(scope.isDone()).toBe(true); }); - + test('should handle localStorage errors gracefully', () => { - // Mock window and localStorage with error const mockLocalStorage = { setItem: vi.fn().mockImplementation(() => { throw new Error('localStorage error'); @@ -454,193 +329,111 @@ describe('Auth Module', () => { }; const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const originalWindow = global.window; - global.window = { - localStorage: mockLocalStorage - }; - - const token = 'test-access-token'; - base44.auth.setToken(token, true); - - // Verify error was logged + global.window = { localStorage: mockLocalStorage }; + + base44.auth.setToken('test-access-token', true); + expect(consoleSpy).toHaveBeenCalledWith('Failed to save token to localStorage:', expect.any(Error)); - - // Restore + consoleSpy.mockRestore(); global.window = originalWindow; }); }); - + describe('loginViaEmailPassword()', () => { test('should login successfully with email and password', async () => { - const loginData = { - email: 'test@example.com', - password: 'password123' - }; - const mockResponse = { access_token: 'test-access-token', - user: { - id: 'user-123', - email: 'test@example.com', - name: 'Test User' - } + user: { id: 'user-123', email: 'test@example.com', name: 'Test User' } }; - - // Mock the API response - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(200, mockResponse); - - // Call the API - const result = await base44.auth.loginViaEmailPassword( - loginData.email, - loginData.password + + server.use( + http.post(loginUrl, () => HttpResponse.json(mockResponse)), + http.get(meUrl, ({ request }) => { + if (request.headers.get('Authorization') === 'Bearer test-access-token') { + return HttpResponse.json({ id: 'user-123', email: 'test@example.com' }); + } + return HttpResponse.json({ detail: 'Unauthorized' }, { status: 401 }); + }) ); - - // Verify the response + + const result = await base44.auth.loginViaEmailPassword('test@example.com', 'password123'); + expect(result.access_token).toBe('test-access-token'); expect(result.user.email).toBe('test@example.com'); - - // Verify token was set in axios headers by making a subsequent request - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', 'Bearer test-access-token') - .reply(200, { id: 'user-123', email: 'test@example.com' }); - + + // Token should be set — subsequent me() should succeed await base44.auth.me(); - expect(scope.isDone()).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('should login with turnstile token when provided', async () => { - const loginData = { - email: 'test@example.com', - password: 'password123', - turnstile_token: 'turnstile-token-123' - }; - + let capturedBody = null; const mockResponse = { access_token: 'test-access-token', - user: { - id: 'user-123', - email: 'test@example.com' - } + user: { id: 'user-123', email: 'test@example.com' } }; - - // Mock the API response - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(200, mockResponse); - - // Call the API + + server.use( + http.post(loginUrl, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(mockResponse); + }), + http.get(meUrl, () => HttpResponse.json({ id: 'user-123', email: 'test@example.com' })) + ); + const result = await base44.auth.loginViaEmailPassword( - loginData.email, - loginData.password, - loginData.turnstile_token + 'test@example.com', + 'password123', + 'turnstile-token-123' ); - - // Verify the response + expect(result.access_token).toBe('test-access-token'); - - // Verify token was set in axios headers by making a subsequent request - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', 'Bearer test-access-token') - .reply(200, { id: 'user-123', email: 'test@example.com' }); - + expect(capturedBody.turnstile_token).toBe('turnstile-token-123'); + await base44.auth.me(); - expect(scope.isDone()).toBe(true); }); - + test('should handle authentication errors and logout', async () => { - const loginData = { - email: 'test@example.com', - password: 'wrongpassword' - }; - - // Mock the API error response - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(401, { detail: 'Invalid credentials' }); - - // Set a token first to test logout + server.use( + http.post(loginUrl, () => HttpResponse.json({ detail: 'Invalid credentials' }, { status: 401 })) + ); + base44.auth.setToken('existing-token', false); - - // Call the API and expect an error + await expect( - base44.auth.loginViaEmailPassword(loginData.email, loginData.password) + base44.auth.loginViaEmailPassword('test@example.com', 'wrongpassword') ).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('should handle network errors', async () => { - const loginData = { - email: 'test@example.com', - password: 'password123' - }; - - // Mock network error - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .replyWithError('Network error'); - - // Call the API and expect an error + server.use(http.post(loginUrl, () => HttpResponse.error())); + await expect( - base44.auth.loginViaEmailPassword(loginData.email, loginData.password) + base44.auth.loginViaEmailPassword('test@example.com', 'password123') ).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); }); - + describe('isAuthenticated()', () => { test('should return true when token is valid', async () => { - const mockUser = { - id: 'user-123', - email: 'test@example.com' - }; - - // Mock the API response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(200, mockUser); - - // Call the API + server.use(http.get(meUrl, () => HttpResponse.json({ id: 'user-123', email: 'test@example.com' }))); + const result = await base44.auth.isAuthenticated(); - - // Verify the response expect(result).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('should return false when token is invalid', async () => { - // Mock the API error response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(401, { detail: 'Unauthorized' }); - - // Call the API + server.use(http.get(meUrl, () => HttpResponse.json({ detail: 'Unauthorized' }, { status: 401 }))); + const result = await base44.auth.isAuthenticated(); - - // Verify the response expect(result).toBe(false); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('should return false on network errors', async () => { - // Mock network error - scope.get(`/api/apps/${appId}/entities/User/me`) - .replyWithError('Network error'); - - // Call the API + server.use(http.get(meUrl, () => HttpResponse.error())); + const result = await base44.auth.isAuthenticated(); - - // Verify the response expect(result).toBe(false); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 4a7b3efa..5d00eca0 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -1,28 +1,28 @@ import { createClient, createClientFromRequest } from '../../src/index.ts'; -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; +import { describe, test, expect, afterEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; describe('Client Creation', () => { test('should create a client with default options', () => { - const client = createClient({ - appId: 'test-app-id', - }); - + const client = createClient({ appId: 'test-app-id' }); + expect(client).toBeDefined(); expect(client.entities).toBeDefined(); expect(client.integrations).toBeDefined(); expect(client.auth).toBeDefined(); expect(client.analytics).toBeDefined(); - + const config = client.getConfig(); expect(config.appId).toBe('test-app-id'); expect(config.serverUrl).toBe('https://base44.app'); expect(config.requiresAuth).toBe(false); - - // Should throw error when accessing asServiceRole without service token + expect(() => client.asServiceRole).toThrow('Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.'); + + client.cleanup(); }); - + test('should create a client with custom options', () => { const client = createClient({ appId: 'test-app-id', @@ -30,21 +30,20 @@ describe('Client Creation', () => { requiresAuth: true, token: 'test-token', }); - + expect(client).toBeDefined(); - + const config = client.getConfig(); expect(config.appId).toBe('test-app-id'); expect(config.serverUrl).toBe('https://custom-server.com'); expect(config.requiresAuth).toBe(true); + + client.cleanup(); }); test('should create a client with service token', () => { - const client = createClient({ - appId: 'test-app-id', - serviceToken: 'service-token-123', - }); - + const client = createClient({ appId: 'test-app-id', serviceToken: 'service-token-123' }); + expect(client).toBeDefined(); expect(client.entities).toBeDefined(); expect(client.integrations).toBeDefined(); @@ -53,8 +52,9 @@ describe('Client Creation', () => { expect(client.asServiceRole.entities).toBeDefined(); expect(client.asServiceRole.integrations).toBeDefined(); expect(client.asServiceRole.functions).toBeDefined(); - // Service role should not have auth module expect(client.asServiceRole.auth).toBeUndefined(); + + client.cleanup(); }); test('should create a client with both user token and service token', () => { @@ -74,60 +74,45 @@ describe('Client Creation', () => { expect(client.asServiceRole.integrations).toBeDefined(); expect(client.asServiceRole.functions).toBeDefined(); expect(client.asServiceRole.auth).toBeUndefined(); - }); + client.cleanup(); + }); }); describe('appBaseUrl Normalization', () => { test('should use appBaseUrl when provided as a string', () => { const customAppBaseUrl = 'https://custom-app.example.com'; - const client = createClient({ - appId: 'test-app-id', - appBaseUrl: customAppBaseUrl, - }); + const client = createClient({ appId: 'test-app-id', appBaseUrl: customAppBaseUrl }); - // Mock window.location const originalWindow = global.window; const mockLocation = { href: '', origin: 'https://current-app.com' }; - global.window = { - location: mockLocation - }; + global.window = { location: mockLocation }; - const nextUrl = 'https://example.com/dashboard'; - client.auth.redirectToLogin(nextUrl); + client.auth.redirectToLogin('https://example.com/dashboard'); - // Verify the redirect URL uses the custom appBaseUrl expect(mockLocation.href).toBe( - `${customAppBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}` + `${customAppBaseUrl}/login?from_url=${encodeURIComponent('https://example.com/dashboard')}` ); - // Restore window global.window = originalWindow; + client.cleanup(); }); test('should normalize appBaseUrl to empty string when not provided', () => { - const client = createClient({ - appId: 'test-app-id', - // appBaseUrl not provided - }); + const client = createClient({ appId: 'test-app-id' }); - // Mock window.location const originalWindow = global.window; const mockLocation = { href: '', origin: 'https://current-app.com' }; - global.window = { - location: mockLocation - }; + global.window = { location: mockLocation }; - const nextUrl = 'https://example.com/dashboard'; - client.auth.redirectToLogin(nextUrl); + client.auth.redirectToLogin('https://example.com/dashboard'); - // Verify the redirect URL uses empty string (relative path) expect(mockLocation.href).toBe( - `/login?from_url=${encodeURIComponent(nextUrl)}` + `/login?from_url=${encodeURIComponent('https://example.com/dashboard')}` ); - // Restore window global.window = originalWindow; + client.cleanup(); }); }); @@ -148,57 +133,57 @@ describe('createClientFromRequest', () => { }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); expect(client.entities).toBeDefined(); expect(client.integrations).toBeDefined(); expect(client.auth).toBeDefined(); expect(client.asServiceRole).toBeDefined(); - + const config = client.getConfig(); expect(config.appId).toBe('test-app-id'); expect(config.serverUrl).toBe('https://custom-server.com'); + + client.cleanup(); }); test('should create client from request with minimal headers', () => { const mockRequest = { headers: { get: (name) => { - const headers = { - 'Base44-App-Id': 'minimal-app-id' - }; + const headers = { 'Base44-App-Id': 'minimal-app-id' }; return headers[name] || null; } } }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); const config = client.getConfig(); expect(config.appId).toBe('minimal-app-id'); - expect(config.serverUrl).toBe('https://base44.app'); // Default value + expect(config.serverUrl).toBe('https://base44.app'); + + client.cleanup(); }); test('should create client with only user token', () => { const mockRequest = { headers: { get: (name) => { - const headers = { - 'Authorization': 'Bearer user-only-token', - 'Base44-App-Id': 'user-app-id' - }; + const headers = { 'Authorization': 'Bearer user-only-token', 'Base44-App-Id': 'user-app-id' }; return headers[name] || null; } } }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); expect(client.auth).toBeDefined(); - // Should throw error when accessing asServiceRole without service token expect(() => client.asServiceRole).toThrow('Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.'); + + client.cleanup(); }); test('should create client with only service token', () => { @@ -215,19 +200,19 @@ describe('createClientFromRequest', () => { }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); expect(client.auth).toBeDefined(); expect(client.asServiceRole).toBeDefined(); + + client.cleanup(); }); test('should throw error when Base44-App-Id header is missing', () => { const mockRequest = { headers: { get: (name) => { - const headers = { - 'Authorization': 'Bearer some-token' - }; + const headers = { 'Authorization': 'Bearer some-token' }; return headers[name] || null; } } @@ -252,7 +237,6 @@ describe('createClientFromRequest', () => { } }; - // Should throw error for malformed headers instead of continuing silently expect(() => createClientFromRequest(mockRequest)).toThrow('Invalid authorization header format. Expected "Bearer "'); }); @@ -270,7 +254,6 @@ describe('createClientFromRequest', () => { } }; - // Should throw error for empty headers instead of continuing silently expect(() => createClientFromRequest(mockRequest)).toThrow('Invalid authorization header format. Expected "Bearer "'); }); @@ -278,199 +261,160 @@ describe('createClientFromRequest', () => { const mockRequest = { headers: { get: (name) => { - const headers = { - 'Base44-App-Id': 'test-app-id', - 'Base44-State': '192.168.1.100' - }; + const headers = { 'Base44-App-Id': 'test-app-id', 'Base44-State': '192.168.1.100' }; return headers[name] || null; } } }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); const config = client.getConfig(); expect(config.appId).toBe('test-app-id'); + + client.cleanup(); }); test('should work without Base44-State header', () => { const mockRequest = { headers: { get: (name) => { - const headers = { - 'Base44-App-Id': 'test-app-id' - }; + const headers = { 'Base44-App-Id': 'test-app-id' }; return headers[name] || null; } } }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); const config = client.getConfig(); expect(config.appId).toBe('test-app-id'); + + client.cleanup(); }); }); describe('Service Role Authorization Headers', () => { - - let scope; const appId = 'test-app-id'; const serverUrl = 'https://api.base44.com'; - - beforeEach(() => { - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on('no match', (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log('Headers:', req.getHeaders()); - }); - }); - - afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners('no match'); - nock.enableNetConnect(); - }); + const entitiesBase = `${serverUrl}/api/apps/${appId}/entities`; + const intBase = `${serverUrl}/api/apps/${appId}/integration-endpoints`; + const functionsBase = `${serverUrl}/api/apps/${appId}/functions`; test('should use user token for regular client operations and service token for service role operations', async () => { const userToken = 'user-token-123'; const serviceToken = 'service-token-456'; - - const client = createClient({ - serverUrl, - appId, - token: userToken, - serviceToken: serviceToken, - }); - - // Mock user entities request (should use user token) - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('Authorization', `Bearer ${userToken}`) - .reply(200, { items: [], total: 0 }); - - // Mock service role entities request (should use service token) - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('Authorization', `Bearer ${serviceToken}`) - .reply(200, { items: [], total: 0 }); + const client = createClient({ serverUrl, appId, token: userToken, serviceToken }); + + const userCalls = []; + const serviceCalls = []; + + server.use( + http.get(`${entitiesBase}/Todo`, ({ request }) => { + const auth = request.headers.get('Authorization'); + if (auth === `Bearer ${userToken}`) userCalls.push('user'); + if (auth === `Bearer ${serviceToken}`) serviceCalls.push('service'); + return HttpResponse.json({ items: [], total: 0 }); + }) + ); - // Make requests await client.entities.Todo.list(); await client.asServiceRole.entities.Todo.list(); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(userCalls).toHaveLength(1); + expect(serviceCalls).toHaveLength(1); + + client.cleanup(); }); test('should use service token for service role entities operations', async () => { const serviceToken = 'service-token-only-123'; - - const client = createClient({ - serverUrl, - appId, - serviceToken: serviceToken, - }); - - // Mock service role entities request - scope.get(`/api/apps/${appId}/entities/User/123`) - .matchHeader('Authorization', `Bearer ${serviceToken}`) - .reply(200, { id: '123', name: 'Test User' }); + const client = createClient({ serverUrl, appId, serviceToken }); + + let capturedAuth = null; + server.use( + http.get(`${entitiesBase}/User/123`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ id: '123', name: 'Test User' }); + }) + ); - // Make request const result = await client.asServiceRole.entities.User.get('123'); - // Verify response expect(result.id).toBe('123'); expect(result.name).toBe('Test User'); + expect(capturedAuth).toBe(`Bearer ${serviceToken}`); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + client.cleanup(); }); test('should use service token for service role integrations operations', async () => { const serviceToken = 'service-token-integration-456'; - - const client = createClient({ - serverUrl, - appId, - serviceToken: serviceToken, - }); - - // Mock service role integrations request - scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`) - .matchHeader('Authorization', `Bearer ${serviceToken}`) - .reply(200, { success: true, messageId: '123' }); + const client = createClient({ serverUrl, appId, serviceToken }); + + let capturedAuth = null; + server.use( + http.post(`${intBase}/Core/SendEmail`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ success: true, messageId: '123' }); + }) + ); - // Make request - const result = await client.asServiceRole.integrations.Core.SendEmail({ + const result = await client.asServiceRole.integrations.Core.SendEmail({ to: 'test@example.com', subject: 'Test', body: 'Test message' }); - // Verify response expect(result.success).toBe(true); expect(result.messageId).toBe('123'); + expect(capturedAuth).toBe(`Bearer ${serviceToken}`); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + client.cleanup(); }); test('should use service token for service role functions operations', async () => { const serviceToken = 'service-token-functions-789'; - - const client = createClient({ - serverUrl, - appId, - serviceToken: serviceToken, - }); - - // Mock service role functions request - scope.post(`/api/apps/${appId}/functions/testFunction`, { param: 'test' }) - .matchHeader('Authorization', `Bearer ${serviceToken}`) - .reply(200, { result: 'function executed' }); + const client = createClient({ serverUrl, appId, serviceToken }); + + let capturedAuth = null; + server.use( + http.post(`${functionsBase}/testFunction`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ result: 'function executed' }); + }) + ); - // Make request - const result = await client.asServiceRole.functions.invoke('testFunction', { - param: 'test' - }); + const result = await client.asServiceRole.functions.invoke('testFunction', { param: 'test' }); - // Verify response expect(result.data.result).toBe('function executed'); + expect(capturedAuth).toBe(`Bearer ${serviceToken}`); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + client.cleanup(); }); test('should use user token for regular operations when both tokens are present', async () => { const userToken = 'user-token-regular-123'; const serviceToken = 'service-token-regular-456'; - - const client = createClient({ - serverUrl, - appId, - token: userToken, - serviceToken: serviceToken, - }); - - // Mock regular user entities request (should use user token) - scope.get(`/api/apps/${appId}/entities/Task`) - .matchHeader('Authorization', `Bearer ${userToken}`) - .reply(200, { items: [{ id: 'task1', title: 'User Task' }], total: 1 }); - - // Mock regular integrations request (should use user token) - scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`) - .matchHeader('Authorization', `Bearer ${userToken}`) - .reply(200, { success: true, messageId: 'email123' }); + const client = createClient({ serverUrl, appId, token: userToken, serviceToken }); + + let taskAuth = null; + let emailAuth = null; + + server.use( + http.get(`${entitiesBase}/Task`, ({ request }) => { + taskAuth = request.headers.get('Authorization'); + return HttpResponse.json({ items: [{ id: 'task1', title: 'User Task' }], total: 1 }); + }), + http.post(`${intBase}/Core/SendEmail`, ({ request }) => { + emailAuth = request.headers.get('Authorization'); + return HttpResponse.json({ success: true, messageId: 'email123' }); + }) + ); - // Make requests using regular client (not service role) const taskResult = await client.entities.Task.list(); const emailResult = await client.integrations.Core.SendEmail({ to: 'user@example.com', @@ -478,39 +422,36 @@ describe('Service Role Authorization Headers', () => { body: 'User message' }); - // Verify responses expect(taskResult.items[0].title).toBe('User Task'); expect(emailResult.success).toBe(true); expect(emailResult.messageId).toBe('email123'); + expect(taskAuth).toBe(`Bearer ${userToken}`); + expect(emailAuth).toBe(`Bearer ${userToken}`); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + client.cleanup(); }); test('should work without authorization header when no tokens are provided', async () => { - const client = createClient({ - serverUrl, - appId, - }); - - // Mock request without authorization header - scope.get(`/api/apps/${appId}/entities/PublicData`) - .matchHeader('Authorization', (val) => !val) // Should not have Authorization header - .reply(200, { items: [{ id: 'public1', data: 'public' }], total: 1 }); + const client = createClient({ serverUrl, appId }); + + let capturedAuth = null; + server.use( + http.get(`${entitiesBase}/PublicData`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ items: [{ id: 'public1', data: 'public' }], total: 1 }); + }) + ); - // Make request const result = await client.entities.PublicData.list(); - // Verify response expect(result.items[0].data).toBe('public'); + expect(capturedAuth).toBeNull(); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + client.cleanup(); }); test('should propagate Base44-State header in API requests when created from request', async () => { const clientIp = '192.168.1.100'; - const mockRequest = { headers: { get: (name) => { @@ -527,17 +468,22 @@ describe('Service Role Authorization Headers', () => { const client = createClientFromRequest(mockRequest); - // Mock entities request and verify Base44-State header is present - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('Base44-State', clientIp) - .matchHeader('Authorization', 'Bearer user-token-123') - .reply(200, { items: [], total: 0 }); + let capturedState = null; + let capturedAuth = null; + server.use( + http.get(`${entitiesBase}/Todo`, ({ request }) => { + capturedState = request.headers.get('Base44-State'); + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ items: [], total: 0 }); + }) + ); - // Make request await client.entities.Todo.list(); - // Verify all mocks were called (including header match) - expect(scope.isDone()).toBe(true); + expect(capturedState).toBe(clientIp); + expect(capturedAuth).toBe('Bearer user-token-123'); + + client.cleanup(); }); test('should not include Base44-State header when not present in original request', async () => { @@ -556,22 +502,23 @@ describe('Service Role Authorization Headers', () => { const client = createClientFromRequest(mockRequest); - // Mock entities request and verify Base44-State header is NOT present - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('Base44-State', (val) => !val) // Should not have this header - .matchHeader('Authorization', 'Bearer user-token-123') - .reply(200, { items: [], total: 0 }); + let capturedState = null; + server.use( + http.get(`${entitiesBase}/Todo`, ({ request }) => { + capturedState = request.headers.get('Base44-State'); + return HttpResponse.json({ items: [], total: 0 }); + }) + ); - // Make request await client.entities.Todo.list(); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(capturedState).toBeNull(); + + client.cleanup(); }); test('should propagate Base44-State header in service role API requests', async () => { const clientIp = '10.0.0.50'; - const mockRequest = { headers: { get: (name) => { @@ -588,20 +535,22 @@ describe('Service Role Authorization Headers', () => { const client = createClientFromRequest(mockRequest); - // Mock service role entities request and verify Base44-State header is present - scope.get(`/api/apps/${appId}/entities/User/123`) - .matchHeader('Base44-State', clientIp) - .matchHeader('Authorization', 'Bearer service-token-123') - .reply(200, { id: '123', name: 'Test User' }); + let capturedState = null; + let capturedAuth = null; + server.use( + http.get(`${entitiesBase}/User/123`, ({ request }) => { + capturedState = request.headers.get('Base44-State'); + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ id: '123', name: 'Test User' }); + }) + ); - // Make request using service role const result = await client.asServiceRole.entities.User.get('123'); - // Verify response expect(result.id).toBe('123'); + expect(capturedState).toBe(clientIp); + expect(capturedAuth).toBe('Bearer service-token-123'); - // Verify all mocks were called (including header match) - expect(scope.isDone()).toBe(true); + client.cleanup(); }); - -}); \ No newline at end of file +}); diff --git a/tests/unit/connectors.test.ts b/tests/unit/connectors.test.ts index 47d5761e..8d17ea23 100644 --- a/tests/unit/connectors.test.ts +++ b/tests/unit/connectors.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; +import { http, HttpResponse } from "msw"; +import { server } from "../mocks/server"; import { createClient } from "../../src/index.ts"; describe("Connectors module – getConnection", () => { @@ -7,81 +8,62 @@ describe("Connectors module – getConnection", () => { const serverUrl = "https://base44.app"; const serviceToken = "service-token-123"; let base44: ReturnType; - let scope: nock.Scope; + const tokensBase = `${serverUrl}/api/apps/${appId}/external-auth/tokens`; beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - serviceToken, - }); - scope = nock(serverUrl); + base44 = createClient({ serverUrl, appId, serviceToken }); }); afterEach(() => { - nock.cleanAll(); + base44.cleanup(); }); test("extracts accessToken and connectionConfig from API response", async () => { - const apiResponse = { - access_token: "oauth-token-abc123", - integration_type: "jira", - connection_config: { subdomain: "my-company" }, - }; - - scope - .get(`/api/apps/${appId}/external-auth/tokens/jira`) - .reply(200, apiResponse); - - const connection = await base44.asServiceRole.connectors.getConnection( - "jira" + server.use( + http.get(`${tokensBase}/jira`, () => + HttpResponse.json({ + access_token: "oauth-token-abc123", + integration_type: "jira", + connection_config: { subdomain: "my-company" }, + }) + ) ); + const connection = await base44.asServiceRole.connectors.getConnection("jira"); + expect(connection).toBeDefined(); expect(connection.accessToken).toBe("oauth-token-abc123"); - expect(connection.connectionConfig).toEqual({ - subdomain: "my-company", - }); - expect(scope.isDone()).toBe(true); + expect(connection.connectionConfig).toEqual({ subdomain: "my-company" }); }); test("returns connectionConfig as null when API omits connection_config", async () => { - const apiResponse = { - access_token: "token-only", - integration_type: "slack", - }; - - scope - .get(`/api/apps/${appId}/external-auth/tokens/slack`) - .reply(200, apiResponse); - - const connection = await base44.asServiceRole.connectors.getConnection( - "slack" + server.use( + http.get(`${tokensBase}/slack`, () => + HttpResponse.json({ access_token: "token-only", integration_type: "slack" }) + ) ); + const connection = await base44.asServiceRole.connectors.getConnection("slack"); + expect(connection.accessToken).toBe("token-only"); expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); }); test("returns connectionConfig as null when API sends null connection_config", async () => { - const apiResponse = { - access_token: "token-only", - integration_type: "github", - connection_config: null, - }; - - scope - .get(`/api/apps/${appId}/external-auth/tokens/github`) - .reply(200, apiResponse); - - const connection = await base44.asServiceRole.connectors.getConnection( - "github" + server.use( + http.get(`${tokensBase}/github`, () => + HttpResponse.json({ + access_token: "token-only", + integration_type: "github", + connection_config: null, + }) + ) ); + const connection = await base44.asServiceRole.connectors.getConnection("github"); + expect(connection.accessToken).toBe("token-only"); expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); }); test("throws when integrationType is empty string", async () => { @@ -92,9 +74,7 @@ describe("Connectors module – getConnection", () => { test("throws when integrationType is not a string", async () => { await expect( - base44.asServiceRole.connectors.getConnection( - null as unknown as string - ) + base44.asServiceRole.connectors.getConnection(null as unknown as string) ).rejects.toThrow("Integration type is required and must be a string"); }); }); diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts index ab34bea5..69aae4b4 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -1,159 +1,138 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; import { createClient } from '../../src/index.ts'; describe('Custom Integrations Module', () => { let base44: ReturnType; - let scope: nock.Scope; const appId = 'test-app-id'; const serverUrl = 'https://base44.app'; + const customBase = `${serverUrl}/api/apps/${appId}/integrations/custom`; + + // The SDK URL-encodes only curly braces in operationIds (not : or /) + function sdkOperationUrl(slug: string, operationId: string): string { + const encoded = operationId.replace(/{/g, '%7B').replace(/}/g, '%7D'); + return `${customBase}/${slug}/${encoded}`; + } + + // Build a RegExp that matches the SDK-generated URL for a given slug + operationId + function operationPattern(slug: string, operationId: string): RegExp { + const encoded = operationId.replace(/{/g, '%7B').replace(/}/g, '%7D') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex special chars + .replace(/%7B/g, '%7B') // restore our intentional encoding + .replace(/%7D/g, '%7D'); + const escapedBase = customBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escapedBase}/${slug}/${encoded}$`); + } beforeEach(() => { - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); + base44 = createClient({ serverUrl, appId }); }); afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); + base44.cleanup(); }); test('custom.call() should convert camelCase params to snake_case for backend', async () => { const slug = 'github'; const operationId = 'get:/repos/{owner}/{repo}/issues'; - - // SDK call uses camelCase (JS convention) - const sdkParams = { + + let capturedBody: Record | null = null; + server.use( + http.post(operationPattern(slug, operationId), async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ + success: true, + status_code: 200, + data: { issues: [{ id: 1, title: 'Test Issue' }] }, + }); + }) + ); + + const result = await base44.integrations.custom.call(slug, operationId, { payload: { title: 'Test Issue' }, pathParams: { owner: 'testuser', repo: 'testrepo' }, queryParams: { state: 'open' }, - }; - - // Backend expects snake_case (Python convention) - const expectedBody = { - payload: { title: 'Test Issue' }, - path_params: { owner: 'testuser', repo: 'testrepo' }, - query_params: { state: 'open' }, - }; - - const mockResponse = { - success: true, - status_code: 200, - data: { issues: [{ id: 1, title: 'Test Issue' }] }, - }; - - // Mock expects snake_case body (curly braces in operationId must be URL-encoded for nock matching) - const encodedOperationId = operationId.replace(/{/g, '%7B').replace(/}/g, '%7D'); - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, expectedBody) - .reply(200, mockResponse); - - // SDK call uses camelCase - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + }); - // Verify the response expect(result.success).toBe(true); expect(result.status_code).toBe(200); expect(result.data.issues).toHaveLength(1); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + // Verify camelCase was converted to snake_case + expect(capturedBody).toMatchObject({ + payload: { title: 'Test Issue' }, + path_params: { owner: 'testuser', repo: 'testrepo' }, + query_params: { state: 'open' }, + }); }); test('custom.call() should work with empty params', async () => { - const slug = 'github'; - const operationId = 'getAuthenticatedUser'; - - const mockResponse = { - success: true, - status_code: 200, - data: { login: 'testuser', id: 123 }, - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) - .reply(200, mockResponse); + server.use( + http.post(`${customBase}/github/getAuthenticatedUser`, () => + HttpResponse.json({ + success: true, + status_code: 200, + data: { login: 'testuser', id: 123 }, + }) + ) + ); - // Call without params - const result = await base44.integrations.custom.call(slug, operationId); + const result = await base44.integrations.custom.call('github', 'getAuthenticatedUser'); - // Verify the response expect(result.success).toBe(true); expect(result.data.login).toBe('testuser'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test('custom.call() should handle 404 error for non-existent integration', async () => { - const slug = 'nonexistent'; - const operationId = 'someEndpoint'; - - // Mock a 404 error response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) - .reply(404, { - detail: `Custom integration '${slug}' not found in workspace`, - }); + server.use( + http.post(`${customBase}/nonexistent/someEndpoint`, () => + HttpResponse.json( + { detail: "Custom integration 'nonexistent' not found in workspace" }, + { status: 404 } + ) + ) + ); - // Call the API and expect an error - await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + await expect(base44.integrations.custom.call('nonexistent', 'someEndpoint')).rejects.toMatchObject({ status: 404, name: 'Base44Error', }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test('custom.call() should handle 404 error for non-existent operation', async () => { - const slug = 'github'; - const operationId = 'nonExistentOperation'; - - // Mock a 404 error response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) - .reply(404, { - detail: `Operation '${operationId}' not found in integration '${slug}'`, - }); + server.use( + http.post(`${customBase}/github/nonExistentOperation`, () => + HttpResponse.json( + { detail: "Operation 'nonExistentOperation' not found in integration 'github'" }, + { status: 404 } + ) + ) + ); - // Call the API and expect an error - await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + await expect(base44.integrations.custom.call('github', 'nonExistentOperation')).rejects.toMatchObject({ status: 404, name: 'Base44Error', }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test('custom.call() should handle 502 error from external API', async () => { const slug = 'github'; const operationId = 'get:/repos/{owner}/{repo}/issues'; - // Mock a 502 error response (external API failure) - curly braces in operationId must be URL-encoded - const encodedOperationId = operationId.replace(/{/g, '%7B').replace(/}/g, '%7D'); - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, {}) - .reply(502, { - detail: 'Failed to connect to external API: Connection refused', - }); + server.use( + http.post(operationPattern(slug, operationId), () => + HttpResponse.json( + { detail: 'Failed to connect to external API: Connection refused' }, + { status: 502 } + ) + ) + ); - // Call the API and expect an error await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ status: 502, name: 'Base44Error', }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test('custom.call() should throw error when slug is missing', async () => { @@ -195,166 +174,106 @@ describe('Custom Integrations Module', () => { }); test('custom.call() should handle large payloads', async () => { - const slug = 'myapi'; - const operationId = 'bulkCreate'; - - // Create a large payload with many items const largeArray = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Item ${i}`, description: 'A'.repeat(100), metadata: { key: `value_${i}` }, })); - - const sdkParams = { - payload: { items: largeArray }, - }; - const mockResponse = { - success: true, - status_code: 200, - data: { created: 1000 }, - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) - .reply(200, mockResponse); + server.use( + http.post(`${customBase}/myapi/bulkCreate`, () => + HttpResponse.json({ success: true, status_code: 200, data: { created: 1000 } }) + ) + ); - // Call the API with large payload - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + const result = await base44.integrations.custom.call('myapi', 'bulkCreate', { + payload: { items: largeArray }, + }); - // Verify the response expect(result.success).toBe(true); expect(result.data.created).toBe(1000); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test('custom.call() should include custom headers in request', async () => { - const slug = 'myapi'; - const operationId = 'getData'; - const sdkParams = { - headers: { 'X-Custom-Header': 'custom-value' }, - }; - - const mockResponse = { - success: true, - status_code: 200, - data: { result: 'ok' }, - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) - .reply(200, mockResponse); + server.use( + http.post(`${customBase}/myapi/getData`, () => + HttpResponse.json({ success: true, status_code: 200, data: { result: 'ok' } }) + ) + ); - // Call the API - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + const result = await base44.integrations.custom.call('myapi', 'getData', { + headers: { 'X-Custom-Header': 'custom-value' }, + }); - // Verify the response expect(result.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test('custom.call() should pass through multiple headers', async () => { - const slug = 'myapi'; - const operationId = 'secureEndpoint'; - const sdkParams = { + server.use( + http.post(`${customBase}/myapi/secureEndpoint`, () => + HttpResponse.json({ success: true, status_code: 200, data: { authenticated: true } }) + ) + ); + + const result = await base44.integrations.custom.call('myapi', 'secureEndpoint', { headers: { 'X-API-Key': 'secret-key-123', 'X-Request-ID': 'req-456', 'Accept-Language': 'en-US', 'X-Custom-Auth': 'Bearer token123', }, - }; - - const mockResponse = { - success: true, - status_code: 200, - data: { authenticated: true }, - }; - - // Mock the API response - verify all headers are passed in the body - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) - .reply(200, mockResponse); - - // Call the API - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + }); - // Verify the response expect(result.success).toBe(true); expect(result.data.authenticated).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test('custom.call() should only include defined params in body', async () => { const slug = 'github'; const operationId = 'get:/users/{username}'; - - // SDK call with only pathParams - const sdkParams = { - pathParams: { username: 'octocat' }, - }; - - // Expected body should only have path_params, not empty payload/query_params/headers - const expectedBody = { - path_params: { username: 'octocat' }, - }; - const mockResponse = { - success: true, - status_code: 200, - data: { login: 'octocat' }, - }; - - // Curly braces in operationId must be URL-encoded for nock matching - const encodedOperationId = operationId.replace(/{/g, '%7B').replace(/}/g, '%7D'); - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, expectedBody) - .reply(200, mockResponse); + let capturedBody: Record | null = null; + server.use( + http.post(operationPattern(slug, operationId), async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ + success: true, + status_code: 200, + data: { login: 'octocat' }, + }); + }) + ); - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + const result = await base44.integrations.custom.call(slug, operationId, { + pathParams: { username: 'octocat' }, + }); expect(result.success).toBe(true); - expect(scope.isDone()).toBe(true); + // Only path_params should be present, not empty payload/query_params/headers + expect(capturedBody).toEqual({ path_params: { username: 'octocat' } }); }); test('custom property should not interfere with other integration packages', async () => { - // Test that Core still works - const coreParams = { + const intBase = `${serverUrl}/api/apps/${appId}/integration-endpoints`; + + server.use( + http.post(`${intBase}/Core/SendEmail`, () => + HttpResponse.json({ success: true }) + ), + http.post(`${intBase}/installable/SomePackage/integration-endpoints/SomeEndpoint`, () => + HttpResponse.json({ success: true }) + ) + ); + + const coreResult = await base44.integrations.Core.SendEmail({ to: 'test@example.com', subject: 'Test', body: 'Test body', - }; - - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`, coreParams) - .reply(200, { success: true }); - - const coreResult = await base44.integrations.Core.SendEmail(coreParams); + }); expect(coreResult.success).toBe(true); - // Test that custom packages still work - const customPackageParams = { param: 'value' }; - - scope - .post( - `/api/apps/${appId}/integration-endpoints/installable/SomePackage/integration-endpoints/SomeEndpoint`, - customPackageParams - ) - .reply(200, { success: true }); - - const packageResult = await base44.integrations.SomePackage.SomeEndpoint(customPackageParams); + const packageResult = await base44.integrations.SomePackage.SomeEndpoint({ param: 'value' }); expect(packageResult.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); }); diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts index e655a054..3834d613 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; +import { http, HttpResponse } from "msw"; +import { server } from "../mocks/server"; import { createClient } from "../../src/index.ts"; import type { DeleteResult, UpdateManyResult } from "../../src/modules/entities.types.ts"; @@ -21,33 +22,16 @@ declare module "../../src/modules/entities.types.ts" { describe("Entities Module", () => { let base44: ReturnType; - let scope: nock.Scope; const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; + const baseUrl = `${serverUrl}/api/apps/${appId}/entities`; beforeEach(() => { - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on("no match", (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log("Headers:", req.getHeaders()); - }); + base44 = createClient({ serverUrl, appId }); }); afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners("no match"); - nock.enableNetConnect(); + base44.cleanup(); }); test("list() should fetch entities with correct parameters", async () => { @@ -56,242 +40,148 @@ describe("Entities Module", () => { { id: "2", title: "Task 2", completed: true }, ]; - // Mock the API response - scope - .get(`/api/apps/${appId}/entities/Todo`) - .query(true) // Accept any query parameters - .reply(200, mockTodos); + server.use( + http.get(`${baseUrl}/Todo`, () => HttpResponse.json(mockTodos)) + ); - // Call the API - const result = await base44.entities.Todo.list("title", 10, 0, [ - "id", - "title", - ]); + const result = await base44.entities.Todo.list("title", 10, 0, ["id", "title"]); - // Verify the response expect(result).toHaveLength(2); expect(result[0].title).toBe("Task 1"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("filter() should send correct query parameters", async () => { - const filterQuery: Partial = { completed: true }; const mockTodos: Todo[] = [{ id: "2", title: "Task 2", completed: true }]; - // Mock the API response - scope - .get(`/api/apps/${appId}/entities/Todo`) - .query((query) => { - // Verify the query contains our filter - const parsedQ = JSON.parse(query.q as string); - return parsedQ.completed === true; + server.use( + http.get(`${baseUrl}/Todo`, ({ request }) => { + const url = new URL(request.url); + const q = url.searchParams.get("q"); + if (q && JSON.parse(q).completed === true) { + return HttpResponse.json(mockTodos); + } + return HttpResponse.json([], { status: 400 }); }) - .reply(200, mockTodos); + ); - // Call the API - const result = await base44.entities.Todo.filter(filterQuery); + const result = await base44.entities.Todo.filter({ completed: true }); - // Verify the response expect(result).toHaveLength(1); expect(result[0].completed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("get() should fetch a single entity", async () => { const todoId = "123"; - const mockTodo: Todo = { - id: todoId, - title: "Get milk", - completed: false, - }; + const mockTodo: Todo = { id: todoId, title: "Get milk", completed: false }; - // Mock the API response - scope.get(`/api/apps/${appId}/entities/Todo/${todoId}`).reply(200, mockTodo); + server.use( + http.get(`${baseUrl}/Todo/${todoId}`, () => HttpResponse.json(mockTodo)) + ); - // Call the API const todo = await base44.entities.Todo.get(todoId); - // Verify the response expect(todo.id).toBe(todoId); expect(todo.title).toBe("Get milk"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("create() should send correct data", async () => { - const newTodo: Partial = { - title: "New task", - completed: false, - }; - const createdTodo: Todo = { - id: "123", - title: "New task", - completed: false, - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/entities/Todo`, newTodo as nock.RequestBodyMatcher) - .reply(201, createdTodo); - - // Call the API + const newTodo: Partial = { title: "New task", completed: false }; + const createdTodo: Todo = { id: "123", title: "New task", completed: false }; + + server.use( + http.post(`${baseUrl}/Todo`, () => HttpResponse.json(createdTodo, { status: 201 })) + ); + const todo = await base44.entities.Todo.create(newTodo); - // Verify the response expect(todo.id).toBe("123"); expect(todo.title).toBe("New task"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("update() should send correct data", async () => { const todoId = "123"; - const updates: Partial = { - title: "Updated task", - completed: true, - }; - const updatedTodo: Todo = { - id: todoId, - title: "Updated task", - completed: true, - }; - - // Mock the API response - scope - .put( - `/api/apps/${appId}/entities/Todo/${todoId}`, - updates as nock.RequestBodyMatcher - ) - .reply(200, updatedTodo); - - // Call the API - const todo = await base44.entities.Todo.update(todoId, updates); - - // Verify the response + const updatedTodo: Todo = { id: todoId, title: "Updated task", completed: true }; + + server.use( + http.put(`${baseUrl}/Todo/${todoId}`, () => HttpResponse.json(updatedTodo)) + ); + + const todo = await base44.entities.Todo.update(todoId, { title: "Updated task", completed: true }); + expect(todo.id).toBe(todoId); expect(todo.title).toBe("Updated task"); expect(todo.completed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("delete() should call correct endpoint and return DeleteResult", async () => { const todoId = "123"; const deleteResult: DeleteResult = { success: true }; - // Mock the API response - scope - .delete(`/api/apps/${appId}/entities/Todo/${todoId}`) - .reply(200, deleteResult); + server.use( + http.delete(`${baseUrl}/Todo/${todoId}`, () => HttpResponse.json(deleteResult)) + ); - // Call the API const result = await base44.entities.Todo.delete(todoId); - // Verify the response matches DeleteResult type expect(result.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("updateMany() should send query and data to correct endpoint", async () => { - const mockResult: UpdateManyResult = { - success: true, - updated: 3, - has_more: false, - }; - - // Mock the API response - scope - .patch(`/api/apps/${appId}/entities/Todo/update-many`, { - query: { completed: false }, - data: { $set: { completed: true } }, + const mockResult: UpdateManyResult = { success: true, updated: 3, has_more: false }; + + server.use( + http.patch(`${baseUrl}/Todo/update-many`, async ({ request }) => { + const body = await request.json() as Record; + expect(body.query).toEqual({ completed: false }); + expect(body.data).toEqual({ $set: { completed: true } }); + return HttpResponse.json(mockResult); }) - .reply(200, mockResult); + ); - // Call the API const result = await base44.entities.Todo.updateMany( { completed: false }, { $set: { completed: true } } ); - // Verify the response expect(result.success).toBe(true); expect(result.updated).toBe(3); expect(result.has_more).toBe(false); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("updateMany() should handle has_more response", async () => { - const mockResult: UpdateManyResult = { - success: true, - updated: 500, - has_more: true, - }; - - // Mock the API response - scope - .patch(`/api/apps/${appId}/entities/Todo/update-many`, { - query: {}, - data: { $inc: { view_count: 1 } }, - }) - .reply(200, mockResult); + const mockResult: UpdateManyResult = { success: true, updated: 500, has_more: true }; - // Call the API - const result = await base44.entities.Todo.updateMany( - {}, - { $inc: { view_count: 1 } } + server.use( + http.patch(`${baseUrl}/Todo/update-many`, () => HttpResponse.json(mockResult)) ); - // Verify the response + const result = await base44.entities.Todo.updateMany({}, { $inc: { view_count: 1 } }); + expect(result.success).toBe(true); expect(result.updated).toBe(500); expect(result.has_more).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("bulkUpdate() should send array of updates to correct endpoint", async () => { - const updatePayload = [ - { id: "1", title: "Updated Task 1", completed: true }, - { id: "2", title: "Updated Task 2" }, - ]; const mockResponse: Todo[] = [ { id: "1", title: "Updated Task 1", completed: true }, { id: "2", title: "Updated Task 2", completed: false }, ]; - // Mock the API response - scope - .put( - `/api/apps/${appId}/entities/Todo/bulk`, - updatePayload as nock.RequestBodyMatcher - ) - .reply(200, mockResponse); + server.use( + http.put(`${baseUrl}/Todo/bulk`, () => HttpResponse.json(mockResponse)) + ); - // Call the API - const result = await base44.entities.Todo.bulkUpdate(updatePayload); + const result = await base44.entities.Todo.bulkUpdate([ + { id: "1", title: "Updated Task 1", completed: true }, + { id: "2", title: "Updated Task 2" }, + ]); - // Verify the response expect(result).toHaveLength(2); expect(result[0].id).toBe("1"); expect(result[0].title).toBe("Updated Task 1"); expect(result[0].completed).toBe(true); expect(result[1].id).toBe("2"); expect(result[1].title).toBe("Updated Task 2"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - }); diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index 9a55379b..67ad9de7 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; -import nock from "nock"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "../mocks/server"; import { createClient } from "../../src/index.ts"; // Module augmentation: register function names in FunctionNameRegistry @@ -13,519 +14,317 @@ declare module "../../src/modules/functions.types.ts" { describe("Functions Module", () => { let base44: ReturnType; - let scope; - let fetchMock: ReturnType; const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; + const functionsBase = `${serverUrl}/api/apps/${appId}/functions`; beforeEach(() => { - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on("no match", (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log("Headers:", req.getHeaders()); - }); - - fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); + base44 = createClient({ serverUrl, appId }); }); afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners("no match"); - nock.enableNetConnect(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); + base44.cleanup(); }); test("should call a function with JSON data", async () => { - const functionName = "sendNotification"; - const functionData = { + server.use( + http.post(`${functionsBase}/sendNotification`, () => + HttpResponse.json({ success: true, messageId: "msg-456" }) + ) + ); + + const result = await base44.functions.invoke("sendNotification", { userId: "123", message: "Hello World", priority: "high", - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { - success: true, - messageId: "msg-456", - }); - - // Call the function - const result = await base44.functions.invoke(functionName, functionData); + }); - // Verify the response expect(result.data.success).toBe(true); expect(result.data.messageId).toBe("msg-456"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle function with empty object parameters", async () => { - const functionName = "getStatus"; - - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, {}) - .matchHeader("Content-Type", "application/json") - .reply(200, { - status: "healthy", - timestamp: "2024-01-01T00:00:00Z", - }); + server.use( + http.post(`${functionsBase}/getStatus`, () => + HttpResponse.json({ status: "healthy", timestamp: "2024-01-01T00:00:00Z" }) + ) + ); - // Call the function - const result = await base44.functions.invoke(functionName, {}); + const result = await base44.functions.invoke("getStatus", {}); - // Verify the response expect(result.data.status).toBe("healthy"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle function with complex nested objects", async () => { - const functionName = "processData"; - const functionData = { + server.use( + http.post(`${functionsBase}/processData`, () => + HttpResponse.json({ processed: true, userId: "123" }) + ) + ); + + const result = await base44.functions.invoke("processData", { user: { id: "123", - profile: { - name: "John Doe", - preferences: { - theme: "dark", - notifications: true, - }, - }, - }, - settings: { - timeout: 5000, - retries: 3, + profile: { name: "John Doe", preferences: { theme: "dark", notifications: true } }, }, - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { - processed: true, - userId: "123", - }); - - // Call the function - const result = await base44.functions.invoke(functionName, functionData); + settings: { timeout: 5000, retries: 3 }, + }); - // Verify the response expect(result.data.processed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle file uploads with FormData", async () => { - const functionName = "uploadFile"; + server.use( + http.post(`${functionsBase}/uploadFile`, () => + HttpResponse.json({ fileId: "file-789", filename: "test.txt", size: 12 }) + ) + ); + const file = new File(["test content"], "test.txt", { type: "text/plain" }); - const functionData = { - file: file, + const result = await base44.functions.invoke("uploadFile", { + file, description: "Test file upload 2", category: "documents", - }; - - // Mock the API response - // TODO: Add validation to the request body - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(() => { - return [ - 200, - { - fileId: "file-789", - filename: "test.txt", - size: 12, - }, - ]; - }); - - // Call the function - const result = await base44.functions.invoke(functionName, functionData); - - // Verify the response + }); + expect(result.data.fileId).toBe("file-789"); expect(result.data.filename).toBe("test.txt"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle mixed data with files and regular data", async () => { - const functionName = "processDocument"; - const file = new File(["document content"], "document.pdf", { - type: "application/pdf", - }); - const functionData = { - file: file, - metadata: { - title: "Important Document", - author: "Jane Smith", - tags: ["important", "confidential"], - }, + server.use( + http.post(`${functionsBase}/processDocument`, () => + HttpResponse.json({ documentId: "doc-123", processed: true, extractedText: "document content" }) + ) + ); + + const file = new File(["document content"], "document.pdf", { type: "application/pdf" }); + const result = await base44.functions.invoke("processDocument", { + file, + metadata: { title: "Important Document", author: "Jane Smith", tags: ["important", "confidential"] }, priority: "high", - }; - - // Mock the API response - // TODO: Add validation to the request body - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { - documentId: "doc-123", - processed: true, - extractedText: "document content", - }); - - // Call the function - const result = await base44.functions.invoke(functionName, functionData); - - // Verify the response + }); + expect(result.data.documentId).toBe("doc-123"); expect(result.data.processed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle FormData input directly", async () => { - const functionName = "submitForm"; + server.use( + http.post(`${functionsBase}/submitForm`, () => + HttpResponse.json({ formId: "form-456", submitted: true }) + ) + ); + const formData = new FormData(); formData.append("name", "John Doe"); formData.append("email", "john@example.com"); formData.append("message", "Hello there"); - // Mock the API response - // TODO: Add validation to the request body - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { - formId: "form-456", - submitted: true, - }); - - // Call the function - const result = await base44.functions.invoke(functionName, formData); + const result = await base44.functions.invoke("submitForm", formData); - // Verify the response expect(result.data.formId).toBe("form-456"); expect(result.data.submitted).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should throw error for string input instead of object", async () => { - const functionName = "processData"; - - // Call the function with string input (should throw) await expect( // @ts-expect-error - base44.functions.invoke(functionName, "invalid string input") + base44.functions.invoke("processData", "invalid string input") ).rejects.toThrow( - `Function ${functionName} must receive an object with named parameters, received: invalid string input` + `Function processData must receive an object with named parameters, received: invalid string input` ); }); test("should handle function names with special characters", async () => { - const functionName = "process-data_v2"; - const functionData = { - input: "test data", - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { - processed: true, - }); - - // Call the function - const result = await base44.functions.invoke(functionName, functionData); - - // Verify the response - expect(result.data.processed).toBe(true); + server.use( + http.post(`${functionsBase}/process-data_v2`, () => + HttpResponse.json({ processed: true }) + ) + ); + + const result = await base44.functions.invoke("process-data_v2", { input: "test data" }); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.data.processed).toBe(true); }); test("should handle API errors gracefully", async () => { - const functionName = "failingFunction"; - const functionData = { - param: "value", - }; - - // Mock the API error response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(500, { - error: "Internal server error", - code: "INTERNAL_ERROR", - }); - - // Call the function and expect it to throw - await expect( - base44.functions.invoke(functionName, functionData) - ).rejects.toThrow(); + server.use( + http.post(`${functionsBase}/failingFunction`, () => + HttpResponse.json({ error: "Internal server error", code: "INTERNAL_ERROR" }, { status: 500 }) + ) + ); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + await expect(base44.functions.invoke("failingFunction", { param: "value" })).rejects.toThrow(); }); test("should handle 404 errors for non-existent functions", async () => { - const functionName = "nonExistentFunction"; - const functionData = { - param: "value", - }; - - // Mock the API 404 response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(404, { - error: "Function not found", - code: "FUNCTION_NOT_FOUND", - }); - - // Call the function and expect it to throw - await expect( - base44.functions.invoke(functionName, functionData) - ).rejects.toThrow(); + server.use( + http.post(`${functionsBase}/nonExistentFunction`, () => + HttpResponse.json({ error: "Function not found", code: "FUNCTION_NOT_FOUND" }, { status: 404 }) + ) + ); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + await expect(base44.functions.invoke("nonExistentFunction", { param: "value" })).rejects.toThrow(); }); test("should handle null and undefined values in data", async () => { - const functionName = "handleNullValues"; - const functionData = { + server.use( + http.post(`${functionsBase}/handleNullValues`, () => + HttpResponse.json({ received: true }) + ) + ); + + const result = await base44.functions.invoke("handleNullValues", { stringValue: "test", nullValue: null, undefinedValue: undefined, emptyString: "", - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { - received: true, - values: functionData, - }); - - // Call the function - const result = await base44.functions.invoke(functionName, functionData); + }); - // Verify the response expect(result.data.received).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle array values in data", async () => { - const functionName = "processArray"; - const functionData = { + server.use( + http.post(`${functionsBase}/processArray`, () => + HttpResponse.json({ processed: true, count: 3 }) + ) + ); + + const result = await base44.functions.invoke("processArray", { numbers: [1, 2, 3, 4, 5], strings: ["a", "b", "c"], mixed: [1, "two", { three: 3 }], - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { - processed: true, - count: 3, - }); - - // Call the function - const result = await base44.functions.invoke(functionName, functionData); + }); - // Verify the response expect(result.data.processed).toBe(true); expect(result.data.count).toBe(3); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should create FormData correctly when files are present", async () => { - const functionName = "uploadFile"; + server.use( + http.post(`${functionsBase}/uploadFile`, () => + HttpResponse.json({ success: true }) + ) + ); + const file = new File(["test content"], "test.txt", { type: "text/plain" }); - const functionData = { - file: file, + const result = await base44.functions.invoke("uploadFile", { + file, description: "Test file upload", category: "documents", - }; - - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { success: true }); - - // Call the function - const result = await base44.functions.invoke(functionName, functionData); + }); - // Verify the response expect(result.data.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should create FormData correctly when FormData is passed directly", async () => { - const functionName = "submitForm"; + server.use( + http.post(`${functionsBase}/submitForm`, () => + HttpResponse.json({ success: true }) + ) + ); + const formData = new FormData(); formData.append("name", "John Doe"); formData.append("email", "john@example.com"); - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { success: true }); - - // Call the function - const result = await base44.functions.invoke(functionName, formData); + const result = await base44.functions.invoke("submitForm", formData); - // Verify the response expect(result.data.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should send user token as Authorization header when invoking functions", async () => { - const functionName = "testAuth"; const userToken = "user-test-token"; - const functionData = { - test: "data", - }; - - // Create client with user token - const authenticatedBase44 = createClient({ - serverUrl, - appId, - token: userToken, - }); - - // Mock the API response, verifying the Authorization header - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .matchHeader("Authorization", `Bearer ${userToken}`) - .reply(200, { - success: true, - authenticated: true, - }); + const authenticatedBase44 = createClient({ serverUrl, appId, token: userToken }); + + let capturedAuth: string | null = null; + server.use( + http.post(`${functionsBase}/testAuth`, ({ request }) => { + capturedAuth = request.headers.get("Authorization"); + return HttpResponse.json({ success: true, authenticated: true }); + }) + ); - // Call the function - const result = await authenticatedBase44.functions.invoke(functionName, functionData); + const result = await authenticatedBase44.functions.invoke("testAuth", { test: "data" }); - // Verify the response expect(result.data.success).toBe(true); expect(result.data.authenticated).toBe(true); + expect(capturedAuth).toBe(`Bearer ${userToken}`); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + authenticatedBase44.cleanup(); }); test("should fetch function endpoint directly", async () => { - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + let capturedUrl: string | null = null; + server.use( + http.get(`${serverUrl}/api/functions/my_function`, ({ request }) => { + capturedUrl = request.url; + return new HttpResponse("ok", { status: 200 }); + }) + ); - await base44.functions.fetch("/my_function", { - method: "GET", - }); + await base44.functions.fetch("/my_function", { method: "GET" }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `${serverUrl}/api/functions/my_function`, - expect.any(Object) - ); + expect(capturedUrl).toBe(`${serverUrl}/api/functions/my_function`); }); - test("should include Authorization header when using functions.fetch", async () => { const userToken = "user-streaming-token"; - const authenticatedBase44 = createClient({ - serverUrl, - appId, - token: userToken, - }); - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + const authenticatedBase44 = createClient({ serverUrl, appId, token: userToken }); + + let capturedAuth: string | null = null; + server.use( + http.post(`${serverUrl}/api/functions/streaming_demo`, ({ request }) => { + capturedAuth = request.headers.get("Authorization"); + return new HttpResponse("ok", { status: 200 }); + }) + ); await authenticatedBase44.functions.fetch("streaming_demo", { method: "POST", body: JSON.stringify({ mode: "text" }), }); - const requestInit = fetchMock.mock.calls[0][1]; - const headers = new Headers(requestInit.headers); - expect(headers.get("Authorization")).toBe(`Bearer ${userToken}`); + expect(capturedAuth).toBe(`Bearer ${userToken}`); + + authenticatedBase44.cleanup(); }); test("should normalize path with and without leading slash", async () => { - // Test with leading slash - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); - await base44.functions.fetch("/my_function"); - expect(fetchMock).toHaveBeenCalledWith( - `${serverUrl}/api/functions/my_function`, - expect.any(Object) + const calledUrls: string[] = []; + server.use( + http.get(`${serverUrl}/api/functions/my_function`, ({ request }) => { + calledUrls.push(request.url); + return new HttpResponse("ok", { status: 200 }); + }) ); - // Test without leading slash - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + await base44.functions.fetch("/my_function"); await base44.functions.fetch("my_function"); - expect(fetchMock).toHaveBeenCalledWith( - `${serverUrl}/api/functions/my_function`, - expect.any(Object) - ); + + expect(calledUrls).toHaveLength(2); + expect(calledUrls[0]).toBe(`${serverUrl}/api/functions/my_function`); + expect(calledUrls[1]).toBe(`${serverUrl}/api/functions/my_function`); }); test("should include service role Authorization header when using asServiceRole.functions.fetch", async () => { const serviceToken = "service-role-token"; - const serviceRoleBase44 = createClient({ - serverUrl, - appId, - serviceToken, - }); - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + const serviceRoleBase44 = createClient({ serverUrl, appId, serviceToken }); + + let capturedAuth: string | null = null; + server.use( + http.get(`${serverUrl}/api/functions/service_function`, ({ request }) => { + capturedAuth = request.headers.get("Authorization"); + return new HttpResponse("ok", { status: 200 }); + }) + ); - await serviceRoleBase44.asServiceRole.functions.fetch("/service_function", { - method: "GET", - }); + await serviceRoleBase44.asServiceRole.functions.fetch("/service_function", { method: "GET" }); + + expect(capturedAuth).toBe(`Bearer ${serviceToken}`); - const requestInit = fetchMock.mock.calls[0][1]; - const headers = new Headers(requestInit.headers); - expect(headers.get("Authorization")).toBe(`Bearer ${serviceToken}`); + serviceRoleBase44.cleanup(); }); }); diff --git a/tests/unit/integrations.test.js b/tests/unit/integrations.test.js index 159c47ee..fc979eb5 100644 --- a/tests/unit/integrations.test.js +++ b/tests/unit/integrations.test.js @@ -1,122 +1,93 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; import { createClient } from '../../src/index.ts'; describe('Integrations Module', () => { let base44; - let scope; const appId = 'test-app-id'; const serverUrl = 'https://base44.app'; - + const intBase = `${serverUrl}/api/apps/${appId}/integration-endpoints`; + beforeEach(() => { - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); + base44 = createClient({ serverUrl, appId }); }); - + afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); + base44.cleanup(); }); - + test('Core integration should send requests to the correct endpoint', async () => { - const emailParams = { + server.use( + http.post(`${intBase}/Core/SendEmail`, () => + HttpResponse.json({ success: true, messageId: '123456' }) + ) + ); + + const result = await base44.integrations.Core.SendEmail({ to: 'test@example.com', subject: 'Test Email', body: 'This is a test email' - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`, emailParams) - .reply(200, { success: true, messageId: '123456' }); - - // Call the API - const result = await base44.integrations.Core.SendEmail(emailParams); - - // Verify the response + }); + expect(result.success).toBe(true); expect(result.messageId).toBe('123456'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('Custom package integration should send requests to the correct endpoint', async () => { - const customParams = { + server.use( + http.post(`${intBase}/installable/CustomPackage/integration-endpoints/CustomEndpoint`, () => + HttpResponse.json({ success: true, result: 'custom result' }) + ) + ); + + const result = await base44.integrations.CustomPackage.CustomEndpoint({ param1: 'value1', param2: 'value2' - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/integration-endpoints/installable/CustomPackage/integration-endpoints/CustomEndpoint`, customParams) - .reply(200, { success: true, result: 'custom result' }); - - // Call the API - const result = await base44.integrations.CustomPackage.CustomEndpoint(customParams); - - // Verify the response + }); + expect(result.success).toBe(true); expect(result.result).toBe('custom result'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('Integration should handle file uploads correctly', async () => { - // Mock a file + server.use( + http.post(`${intBase}/Core/UploadFile`, () => + HttpResponse.json({ success: true, fileId: 'file123' }) + ) + ); + const mockFile = new Blob(['file content'], { type: 'text/plain' }); mockFile.name = 'test.txt'; - - const uploadParams = { + + const result = await base44.integrations.Core.UploadFile({ file: mockFile, metadata: { type: 'document' } - }; - - // Mock the API response - note that we can't easily check FormData contents with nock - // so we just make sure the endpoint is called - scope.post(`/api/apps/${appId}/integration-endpoints/Core/UploadFile`) - .reply(200, { success: true, fileId: 'file123' }); - - // Call the API - const result = await base44.integrations.Core.UploadFile(uploadParams); - - // Verify the response + }); + expect(result.success).toBe(true); expect(result.fileId).toBe('file123'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - + test('Integration should throw error with string parameters', async () => { - // Expect error when trying to call with a string instead of object await expect(async () => { await base44.integrations.Core.SendEmail('invalid string parameter'); }).rejects.toThrow('Integration SendEmail must receive an object with named parameters'); }); - + test('Integration should handle API errors correctly', async () => { - const params = { invalid: 'params' }; - - // Mock an API error response - scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`, params) - .reply(400, { detail: 'Invalid parameters', code: 'INVALID_PARAMS' }); - - // Call the API and expect an error - await expect(base44.integrations.Core.SendEmail(params)) + server.use( + http.post(`${intBase}/Core/SendEmail`, () => + HttpResponse.json({ detail: 'Invalid parameters', code: 'INVALID_PARAMS' }, { status: 400 }) + ) + ); + + await expect(base44.integrations.Core.SendEmail({ invalid: 'params' })) .rejects.toMatchObject({ status: 400, name: 'Base44Error', message: 'Invalid parameters', code: 'INVALID_PARAMS' }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); -}); \ No newline at end of file +}); From 136985c2c9dd6df4982d9977fcd4da9c93f00df3 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 8 Sep 2026 14:27:03 +0300 Subject: [PATCH 2/9] fix(functions): preserve caller-supplied FormData contents --- src/modules/functions.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 1a72c1d3..6f362ee9 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -56,10 +56,11 @@ export function createFunctionsModule( let contentType: string; // Handle file uploads with FormData - if ( - data instanceof FormData || - (data && Object.values(data).some((value) => value instanceof File)) - ) { + if (data instanceof FormData) { + // Preserve fields, repeated keys, and files already encoded by callers. + formData = data; + contentType = "multipart/form-data"; + } else if (data && Object.values(data).some((value) => value instanceof File)) { formData = new FormData(); Object.keys(data).forEach((key) => { if (data[key] instanceof File) { From 1af6e1b5a436dd31e79048f6ea4b9d4696d84f51 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 8 Sep 2026 14:27:24 +0300 Subject: [PATCH 3/9] test: migrate current SDK HTTP contracts to strict MSW --- package-lock.json | 599 +++++++++++++++- package.json | 4 +- tests/README.md | 58 ++ tests/mocks/http.ts | 126 ++++ tests/mocks/server.ts | 47 ++ tests/setup.e2e.js | 4 + tests/setup.js | 57 +- tests/unit/actors.test.ts | 245 +++++-- tests/unit/agents.test.ts | 91 ++- tests/unit/analytics.test.ts | 45 +- tests/unit/app.test.ts | 53 +- tests/unit/auth-registration.test.ts | 90 +++ tests/unit/auth.test.js | 902 ++++++++++++++----------- tests/unit/client.test.js | 630 +++++++++-------- tests/unit/connectors-proxy.test.ts | 187 +++-- tests/unit/connectors.test.ts | 182 ++--- tests/unit/custom-integrations.test.ts | 405 ++++++----- tests/unit/entities-subscribe.test.ts | 71 +- tests/unit/entities.test.ts | 169 +++-- tests/unit/fetch-with-auth.test.ts | 88 ++- tests/unit/functions.test.ts | 408 ++++++----- tests/unit/integrations.test.js | 171 ++--- tests/unit/integrations.test.ts | 79 ++- tests/unit/sso.test.ts | 70 +- vitest.config.ts | 2 +- vitest.e2e.config.ts | 18 + 26 files changed, 3080 insertions(+), 1721 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/mocks/http.ts create mode 100644 tests/mocks/server.ts create mode 100644 tests/setup.e2e.js create mode 100644 tests/unit/auth-registration.test.ts create mode 100644 vitest.e2e.config.ts diff --git a/package-lock.json b/package-lock.json index 41f0fd47..69f65845 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "dotenv": "^16.3.1", "eslint": "^9.39.2", "eslint-plugin-import": "^2.32.0", - "nock": "^13.4.0", + "msw": "^2.12.11", "typedoc": "^0.28.14", "typedoc-plugin-markdown": "^4.9.0", "typescript": "^5.3.2", @@ -534,6 +534,93 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.2.tgz", + "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -594,6 +681,31 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -613,6 +725,31 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1033,6 +1170,23 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1552,6 +1706,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1942,6 +2106,31 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1988,6 +2177,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2183,6 +2386,13 @@ "dev": true, "license": "ISC" }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/engine.io-client": { "version": "6.6.6", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", @@ -2730,6 +2940,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -2915,6 +3152,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3025,6 +3272,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3116,6 +3373,17 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3349,6 +3617,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -3408,6 +3686,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -3687,13 +3972,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4194,6 +4472,61 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -4220,21 +4553,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nock": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", - "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "json-stringify-safe": "^5.0.1", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">= 10.13" - } - }, "node_modules/node-releases": { "version": "2.0.48", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", @@ -4374,6 +4692,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -4473,6 +4798,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4549,16 +4881,6 @@ "node": ">= 0.8.0" } }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -4632,6 +4954,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4663,6 +4995,13 @@ "node": ">=4" } }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -4765,6 +5104,13 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -4920,6 +5266,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -4980,6 +5339,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -5001,6 +5370,28 @@ "node": ">= 0.4" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -5060,6 +5451,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -5109,6 +5513,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5171,6 +5588,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -5181,6 +5618,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -5241,6 +5691,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -5453,6 +5919,16 @@ "dev": true, "license": "MIT" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5807,6 +6283,24 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -5836,6 +6330,16 @@ "node": ">=0.4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5859,6 +6363,35 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 80c89a70..a85fe6ab 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "npm run test:types && vitest run", "test:types": "tsc --noEmit -p tsconfig.type-tests.json", "test:unit": "vitest run tests/unit", - "test:e2e": "vitest run tests/e2e", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "docs": "typedoc", @@ -42,7 +42,7 @@ "dotenv": "^16.3.1", "eslint": "^9.39.2", "eslint-plugin-import": "^2.32.0", - "nock": "^13.4.0", + "msw": "^2.12.11", "typedoc": "^0.28.14", "typedoc-plugin-markdown": "^4.9.0", "typescript": "^5.3.2", diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..70e43ff9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,58 @@ +# SDK HTTP tests + +`npm test` runs TypeScript API tests and the hermetic unit suite. `npm run test:coverage` reports unit coverage. Tests exercise the actual SDK HTTP clients through MSW v2; no API credentials or `tests/.env` are loaded. Unexpected traffic fails the test even when the SDK catches the network error. Never change the unit server to `warn` or `bypass` to make a test pass. + +## Add an HTTP contract + +Use `mockHttp` for finite request expectations. It registers native MSW handlers, captures requests, and checks bodies, headers, query parameters and call counts in teardown. This preserves request assertions on error paths: throwing directly in an MSW resolver becomes a 500 response, which an error-handling test may accidentally accept. + +```ts +import { mockHttp } from '../mocks/http'; + +mockHttp({ + method: 'post', + url: 'https://api.base44.com/api/apps/test-app/entities/Todo', + body: { title: 'Write a test' }, + headers: [['authorization', 'Bearer test-token']], + status: 201, + response: { id: 'todo-1', title: 'Write a test' }, +}); +const todo = await client.entities.Todo.create({ title: 'Write a test' }); +expect(todo.id).toBe('todo-1'); +``` + +Each expectation defaults to one call; set `times` for repeated requests. Register the same method/URL several times for ordered responses. URL matching is exact, including escaped operation IDs; query checks are explicit. `networkError: true` simulates a transport failure; `delayMs` tests concurrent request behavior. + +For multipart or binary payloads, use `inspect: async (request) => { ... }`. Parse `await request.formData()`, then assert field values, repeated keys, file names/MIME types and file bytes. These assertions are captured and rethrown in teardown, independently of the HTTP response. `body` predicates can capture JSON requests for assertions in the test. `mockHttp` is intentionally a small test fixture, not a simulation of backend business logic. + +For streams, dynamic state or other specialized behavior, use MSW directly: + +```ts +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +let received: unknown; +server.use(http.post('https://example.test/api/example', async ({ request }) => { + received = await request.json(); + return HttpResponse.json({ ok: true }); +})); +await clientOperation(); +expect(received).toEqual({ expected: 'payload' }); +``` + +Keep assertions outside direct resolvers. Return explicit status codes/error bodies; do not add permissive fallback handlers. Cleanup clients with `client.cleanup()` after each test, and reset any browser globals/timers installed by the test. Global setup always removes per-test handlers and verifies expected/unexpected traffic. Tests using timers must drain pending SDK work before teardown. + +## Coverage locations + +- `entities.test.ts`: list/filter/get/create/update/delete/deleteMany/bulkCreate/updateMany, including advanced query syntax. +- `functions.test.ts`: JSON, multipart objects, caller-supplied FormData (including repeated keys and binary files), raw fetch and user/service-role headers. +- `auth.test.js`, `auth-registration.test.ts`, `sso.test.ts`: current user, login, concurrent identity transitions, registration, password reset and SSO token transport. +- `agents.test.ts`, `actors.test.ts`: agent conversations/messages and actor connection-token HTTP contracts. WebSocket constructors remain separate non-HTTP test doubles. +- `integrations.test.js`, `integrations.test.ts`, `custom-integrations.test.ts`, `connectors*.test.ts`: integration payloads/errors, tokens, scoped connections and metered proxy calls. +- `fetch-with-auth.test.ts`, `analytics.test.ts`, `app.test.ts`, `client.test.js`: fetch auth/path behavior, analytics traffic, public settings and request-derived headers. + +The fixtures are grounded in current SDK wire contracts, not a claim that every real backend route has been independently validated. Live E2E tests remain a separate check. + +## Explicit live E2E tests + +`BASE44_RUN_E2E=true npm run test:e2e` uses `vitest.e2e.config.ts`, loads `tests/.env`, and bypasses MSW entirely. Supply a dedicated disposable test application via `BASE44_SERVER_URL`, `BASE44_APP_ID`, and `BASE44_AUTH_TOKEN`. These tests can create/delete platform data. They are excluded from `npm test` and unit coverage. Running `npm run test:e2e` without opt-in fails before tests or network calls begin. diff --git a/tests/mocks/http.ts b/tests/mocks/http.ts new file mode 100644 index 00000000..20d0b08a --- /dev/null +++ b/tests/mocks/http.ts @@ -0,0 +1,126 @@ +import { expect } from "vitest"; +import { http, HttpResponse, delay } from "msw"; +import { server } from "./server"; + +type HeaderExpectation = + string | RegExp | ((value: string | undefined) => boolean); +interface HttpExpectation { + method: "get" | "post" | "put" | "patch" | "delete"; + url: string; + body?: any; + inspect?: (request: Request) => void | Promise; + query?: + | true + | Record + | ((query: Record) => boolean); + headers?: [string, HeaderExpectation][]; + reqheaders?: Record; + badheaders?: string[]; + times?: number; + delayMs?: number; + networkError?: boolean; + status?: number; + response?: any; + responseHeaders?: Record; + respond?: (request: Request) => [number, any]; +} +interface Capture { + request: Request; + body: unknown; +} +const expectations: { + expected: HttpExpectation; + requests: Capture[]; + failures: unknown[]; +}[] = []; + +/** A native MSW handler with after-test request contract verification. + * Resolver assertions cannot fail a test reliably: MSW translates exceptions + * into HTTP 500 responses. Capturing them separately also covers error paths. + * Repeated registrations for one route form a response sequence, in order. + */ +export function mockHttp(expected: HttpExpectation) { + expectations.push({ expected, requests: [], failures: [] }); + // Use an exact regex: operation IDs may contain MSW path-pattern metacharacters. + const url = new RegExp( + "^" + expected.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?:\\?.*)?$", + ); + server.use( + http[expected.method](url, async ({ request }) => { + const sameRoute = expectations.filter( + (item) => + item.expected.method === expected.method && + item.expected.url === expected.url, + ); + const item = + sameRoute.find( + (item) => item.requests.length < (item.expected.times ?? 1), + ) ?? sameRoute.at(-1)!; + const rule = item.expected; + const text = request.body ? await request.clone().text() : ""; + let body: unknown = text; + if ( + text && + request.headers.get("content-type")?.includes("application/json") + ) + body = JSON.parse(text); + item.requests.push({ request, body }); + if (typeof rule.body === "function") { + try { + expect(rule.body(body)).toBe(true); + } catch (error) { + item.failures.push(error); + } + } + if (rule.inspect) { + try { + await rule.inspect(request.clone()); + } catch (error) { + item.failures.push(error); + } + } + if (rule.delayMs) await delay(rule.delayMs); + if (rule.networkError) return HttpResponse.error(); + const [status, response] = rule.respond?.(request) ?? [ + rule.status ?? 200, + rule.response, + ]; + const init = { status, headers: rule.responseHeaders }; + return typeof response === "string" + ? new HttpResponse(response, init) + : HttpResponse.json(response, init); + }), + ); +} + +export function verifyHttpExpectations() { + const pending = expectations.splice(0); + for (const { expected, requests, failures } of pending) { + if (failures.length) throw failures[0]; + expect( + requests, + `${expected.method.toUpperCase()} ${expected.url} request count`, + ).toHaveLength(expected.times ?? 1); + for (const { request, body } of requests) { + if ("body" in expected && typeof expected.body !== "function") + expect(body).toEqual(expected.body); + if (expected.query && expected.query !== true) { + const query = Object.fromEntries(new URL(request.url).searchParams); + if (typeof expected.query === "function") + expect(expected.query(query)).toBe(true); + else expect(query).toEqual(expected.query); + } + for (const [name, value] of [ + ...Object.entries(expected.reqheaders ?? {}), + ...(expected.headers ?? []), + ]) { + const actual = request.headers.get(name) ?? undefined; + if (typeof value === "function") expect(value(actual)).toBe(true); + else if (value instanceof RegExp) expect(actual).toMatch(value); + else expect(actual, `header ${name}`).toBe(value); + } + for (const name of expected.badheaders ?? []) + expect(request.headers.has(name), `absent header ${name}`).toBe(false); + } + } +} diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts new file mode 100644 index 00000000..bee93fd5 --- /dev/null +++ b/tests/mocks/server.ts @@ -0,0 +1,47 @@ +/** + * MSW (Mock Service Worker) server for unit tests. + * + * ## How to add new handlers + * + * Call `server.use()` inside a test to register per-test handlers. + * They are automatically removed after each test by the global `afterEach` + * in `tests/setup.js` (via `server.resetHandlers()`). + * + * ```ts + * import { http, HttpResponse } from 'msw'; + * import { server } from '../mocks/server'; + * + * test('my test', async () => { + * server.use( + * http.get('https://api.base44.com/api/apps/test-app-id/entities/Todo', () => + * HttpResponse.json([{ id: '1', title: 'Test' }]) + * ) + * ); + * // ... test code + * }); + * ``` + * + * ## Architecture + * + * ``` + * Vitest test → SDK (axios / fetch) → MSW Node server → handler → fake response + * ``` + * + * MSW intercepts requests at the Node.js http layer (`@mswjs/interceptors`) + * and also intercepts native `fetch` calls. No axios mocking or `vi.stubGlobal` + * needed. + * + * ## Modules and their base URL patterns + * + * | Module | Base path | + * |--------------|------------------------------------------------------------------| + * | entities | `/api/apps/:appId/entities/:entityName` | + * | auth | `/api/apps/:appId/entities/User/me`, `/api/apps/:appId/auth/...` | + * | functions | `/api/apps/:appId/functions/:name`, `/api/functions/:name` | + * | integrations | `/api/apps/:appId/integration-endpoints/:pkg/:endpoint` | + * | custom-int | `/api/apps/:appId/integrations/custom/:slug/:operationId` | + * | connectors | `/api/apps/:appId/external-auth/tokens/:type` | + */ +import { setupServer } from "msw/node"; + +export const server = setupServer(); diff --git a/tests/setup.e2e.js b/tests/setup.e2e.js new file mode 100644 index 00000000..2fbf846c --- /dev/null +++ b/tests/setup.e2e.js @@ -0,0 +1,4 @@ +import dotenv from "dotenv"; +import "./utils/circular-json-handler.js"; +// Explicit live-E2E configuration only. Unit tests never read this file. +dotenv.config({ path: "./tests/.env" }); diff --git a/tests/setup.js b/tests/setup.js index 901e0f24..ecba0ff4 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,28 +1,35 @@ -// Load environment variables from .env file -import dotenv from 'dotenv'; -import './utils/circular-json-handler.js'; -import { beforeAll, afterAll, test } from 'vitest'; +import { beforeAll, afterAll, afterEach, expect } from "vitest"; +import { server } from "./mocks/server.ts"; +import { verifyHttpExpectations } from "./mocks/http.ts"; -try { - dotenv.config({ path: './tests/.env' }); -} catch (err) { - console.warn('dotenv package not found or .env file missing, skipping environment loading'); -} - -// Load circular JSON reference handler to prevent errors in Jest -try { - console.log('Loaded circular JSON reference handler'); -} catch (err) { - console.warn('Failed to load circular JSON handler:', err.message); -} - -// Global beforeAll and afterAll hooks +const unexpected = []; beforeAll(() => { - console.log('Starting Base44 SDK tests...'); - // Add any global setup here + server.listen({ + onUnhandledRequest(request, print) { + unexpected.push(`${request.method} ${request.url}`); + print.error(); // Never allow a unit test to reach the real network. + }, + }); }); - -afterAll(() => { - console.log('Completed Base44 SDK tests'); - // Add any global teardown here -}); \ No newline at end of file +afterEach(() => { + const failures = []; + try { + // A method swallowing network errors must still fail on unexpected traffic. + try { + expect(unexpected.splice(0), "Unhandled HTTP requests").toEqual([]); + } catch (error) { + failures.push(error); + } + // This also drains expectations when the unexpected-request check failed. + try { + verifyHttpExpectations(); + } catch (error) { + failures.push(error); + } + } finally { + server.resetHandlers(); + } + if (failures.length) + throw new AggregateError(failures, "HTTP mock contract failed"); +}); +afterAll(() => server.close()); diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index e79516e1..11c785e7 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -1,5 +1,5 @@ +import { mockHttp } from "../mocks/http"; import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import nock from "nock"; // Mock ReconnectingWebSocket (partysocket's `WebSocket` export) with a // controllable fake. It records the async URL provider so tests can drive @@ -23,11 +23,21 @@ const { sockets, FakeSocket } = vi.hoisted(() => { addEventListener(type: string, fn: (ev: any) => void) { (this.handlers[type] ??= []).push(fn); } - send(data: string) { this.sent.push(data); } - close() { this.closed = true; } - reconnect() { this.reconnects++; } - emit(type: string, ev: any) { (this.handlers[type] ?? []).forEach((h) => h(ev)); } - message(obj: unknown) { this.emit("message", { data: JSON.stringify(obj) }); } + send(data: string) { + this.sent.push(data); + } + close() { + this.closed = true; + } + reconnect() { + this.reconnects++; + } + emit(type: string, ev: any) { + (this.handlers[type] ?? []).forEach((h) => h(ev)); + } + message(obj: unknown) { + this.emit("message", { data: JSON.stringify(obj) }); + } } const sockets: InstanceType[] = []; return { sockets, FakeSocket }; @@ -67,7 +77,9 @@ describe("Actors Module — connection API", () => { // The module (Proxy of actor names). closeAll is separate — see its own tests. const mod = (c = makeConfig()) => createActorsModule(c).module; - beforeEach(() => { sockets.length = 0; }); + beforeEach(() => { + sockets.length = 0; + }); test("connect() opens exactly one socket that dials the minted direct URL", async () => { const config = makeConfig(); @@ -78,7 +90,9 @@ describe("Actors Module — connection API", () => { `${DIRECT_URL}&token=jwt.abc.def`, ); expect(config.mintConnectionToken).toHaveBeenCalledWith( - "GameRoom", "room-1", "conn-1", + "GameRoom", + "room-1", + "conn-1", ); }); @@ -120,8 +134,12 @@ describe("Actors Module — connection API", () => { .mockResolvedValueOnce({ websocket_url: DIRECT_URL, token: "first" }) .mockResolvedValueOnce({ websocket_url: DIRECT_URL, token: "second" }); mod(config).GameRoom("r").connect(); - await expect(sockets[0].urlProvider()).resolves.toBe(`${DIRECT_URL}&token=first`); - await expect(sockets[0].urlProvider()).resolves.toBe(`${DIRECT_URL}&token=second`); + await expect(sockets[0].urlProvider()).resolves.toBe( + `${DIRECT_URL}&token=first`, + ); + await expect(sockets[0].urlProvider()).resolves.toBe( + `${DIRECT_URL}&token=second`, + ); expect(config.mintConnectionToken).toHaveBeenCalledTimes(2); }); @@ -132,12 +150,17 @@ describe("Actors Module — connection API", () => { /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ); await sockets[0].urlProvider(); - expect(config.mintConnectionToken).toHaveBeenCalledWith("GameRoom", "r", conn.id); + expect(config.mintConnectionToken).toHaveBeenCalledWith( + "GameRoom", + "r", + conn.id, + ); }); test("multiple listeners all receive; unsubscribe removes only its own", () => { const conn = mod().GameRoom("r").connect(); - const a: unknown[] = [], b: unknown[] = []; + const a: unknown[] = [], + b: unknown[] = []; const subA = conn.subscribe((m) => a.push(m)); conn.subscribe((m) => b.push(m)); @@ -164,7 +187,9 @@ describe("Actors Module — connection API", () => { test("send serializes onto the socket", () => { const conn = mod().GameRoom("r").connect(); conn.send({ type: "join", name: "alice" }); - expect(sockets[0].sent).toContain(JSON.stringify({ type: "join", name: "alice" })); + expect(sockets[0].sent).toContain( + JSON.stringify({ type: "join", name: "alice" }), + ); }); test("close() tears down socket and all listeners", () => { @@ -253,7 +278,9 @@ describe("Actors Module — connection API", () => { describe("Actors Module — proxy fallback", () => { const mod = (c = makeConfig()) => createActorsModule(c).module; - beforeEach(() => { sockets.length = 0; }); + beforeEach(() => { + sockets.length = 0; + }); test("mint 409 (legacy actor) falls back to the exact legacy proxy URL", async () => { const config = makeConfig(); @@ -264,14 +291,17 @@ describe("Actors Module — proxy fallback", () => { ); }); - test.each([503, 422, 405])("mint %i falls back to the proxy", async (status) => { - const config = makeConfig(); - config.mintConnectionToken.mockRejectedValueOnce(httpError(status)); - mod(config).GameRoom("r").connect({ id: "c" }); - await expect(sockets[0].urlProvider()).resolves.toContain( - "wss://app.example/parties/GameRoom/r?_pk=c", - ); - }); + test.each([503, 422, 405])( + "mint %i falls back to the proxy", + async (status) => { + const config = makeConfig(); + config.mintConnectionToken.mockRejectedValueOnce(httpError(status)); + mod(config).GameRoom("r").connect({ id: "c" }); + await expect(sockets[0].urlProvider()).resolves.toContain( + "wss://app.example/parties/GameRoom/r?_pk=c", + ); + }, + ); test("fallback is sticky per connection; a fresh connect() probes direct again", async () => { const config = makeConfig(); @@ -320,7 +350,9 @@ describe("Actors Module — proxy fallback", () => { const config = makeConfig(); config.transport = "proxy"; mod(config).GameRoom("r").connect({ id: "c" }); - await expect(sockets[0].urlProvider()).resolves.toContain("/parties/GameRoom/r"); + await expect(sockets[0].urlProvider()).resolves.toContain( + "/parties/GameRoom/r", + ); expect(config.mintConnectionToken).not.toHaveBeenCalled(); }); @@ -337,7 +369,9 @@ describe("Actors Module — proxy fallback", () => { describe("Actors Module — terminal mint failures", () => { const mod = (c = makeConfig()) => createActorsModule(c).module; - beforeEach(() => { sockets.length = 0; }); + beforeEach(() => { + sockets.length = 0; + }); test.each([400, 403, 404])( "mint %i closes the connection permanently", @@ -415,7 +449,9 @@ describe("Actors Module — terminal mint failures", () => { describe("Actors Module — mint error reporting", () => { const mod = (c = makeConfig()) => createActorsModule(c).module; - beforeEach(() => { sockets.length = 0; }); + beforeEach(() => { + sockets.length = 0; + }); test("a retryable mint error reaches onMintError once, unmodified", async () => { const config = makeConfig(); @@ -515,24 +551,35 @@ describe("buildProxyActorUrl", () => { }); test("token and fv are omitted when absent", () => { - const u = buildProxyActorUrl("https://h.example", "A", "r", "c", "app", null); + const u = buildProxyActorUrl( + "https://h.example", + "A", + "r", + "c", + "app", + null, + ); expect(u).toBe("wss://h.example/parties/A/r?_pk=c&app_id=app&handler=A"); }); }); describe("resolveActorsHost", () => { test("absolute serverUrl is used as-is", () => { - expect(resolveActorsHost("https://api.example", "https://tab.example")).toBe( - "https://api.example", - ); + expect( + resolveActorsHost("https://api.example", "https://tab.example"), + ).toBe("https://api.example"); }); test("empty serverUrl falls back to the browser origin", () => { - expect(resolveActorsHost("", "https://tab.example")).toBe("https://tab.example"); + expect(resolveActorsHost("", "https://tab.example")).toBe( + "https://tab.example", + ); }); test("relative serverUrl (/api) falls back to the browser origin", () => { - expect(resolveActorsHost("/api", "https://tab.example")).toBe("https://tab.example"); + expect(resolveActorsHost("/api", "https://tab.example")).toBe( + "https://tab.example", + ); }); test("no origin available (non-browser) returns the serverUrl unchanged", () => { @@ -544,30 +591,36 @@ describe("Actors Module — client wiring", () => { const serverUrl = "https://base44.app"; const appId = "app-1"; - beforeEach(() => { sockets.length = 0; }); + beforeEach(() => { + sockets.length = 0; + }); afterEach(() => { - nock.cleanAll(); vi.unstubAllGlobals(); }); test("mints via POST /connection-token with app, auth, and version headers", async () => { - const scope = nock(serverUrl, { - reqheaders: { - "x-app-id": appId, - authorization: "Bearer tok", - "base44-functions-version": "draft", + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, + ...{ + reqheaders: { + "x-app-id": appId, + authorization: "Bearer tok", + "base44-functions-version": "draft", + }, }, - }) - .post(`/api/apps/${appId}/actors/PongGame/connection-token`, { + body: { room: "r1", connection_id: "c1", - }) - .reply(200, { + }, + status: 200, + response: { websocket_url: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", token: "jwt.min.ted", expires_at: "2026-01-01T00:00:00Z", mode: "preview", - }); + }, + }); const base44 = createClient({ serverUrl, @@ -579,17 +632,26 @@ describe("Actors Module — client wiring", () => { await expect(sockets[0].urlProvider()).resolves.toBe( "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1&token=jwt.min.ted", ); - expect(scope.isDone()).toBe(true); base44.cleanup(); }); test("a 409 mint reply falls back to the legacy proxy URL without calling onError", async () => { - nock(serverUrl) - .post(`/api/apps/${appId}/actors/PongGame/connection-token`) - .reply(409, { message: "Actor must be migrated before connecting directly" }); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, + status: 409, + response: { + message: "Actor must be migrated before connecting directly", + }, + }); const onError = vi.fn(); - const base44 = createClient({ serverUrl, appId, token: "tok", options: { onError } }); + const base44 = createClient({ + serverUrl, + appId, + token: "tok", + options: { onError }, + }); base44.actors.PongGame("r1").connect({ id: "c1" }); await expect(sockets[0].urlProvider()).resolves.toBe( "wss://base44.app/parties/PongGame/r1?_pk=c1&app_id=app-1&handler=PongGame&token=tok", @@ -602,16 +664,24 @@ describe("Actors Module — client wiring", () => { test("a 405 mint reply (backend without the endpoint) falls back to the proxy", async () => { // What a pre-direct backend actually answers: its actor deploy routes // match the path via `{handler_name:path}` but not the POST method. - nock(serverUrl) - .post(`/api/apps/${appId}/actors/PongGame/connection-token`) - .reply(405, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, + status: 405, + response: { error_type: "HTTPException", message: "Method Not Allowed", detail: "Method Not Allowed", - }); + }, + }); const onError = vi.fn(); - const base44 = createClient({ serverUrl, appId, token: "tok", options: { onError } }); + const base44 = createClient({ + serverUrl, + appId, + token: "tok", + options: { onError }, + }); base44.actors.PongGame("r1").connect({ id: "c1" }); await expect(sockets[0].urlProvider()).resolves.toBe( "wss://base44.app/parties/PongGame/r1?_pk=c1&app_id=app-1&handler=PongGame&token=tok", @@ -621,12 +691,20 @@ describe("Actors Module — client wiring", () => { }); test("a non-fallback mint failure reaches the client's onError as a Base44Error", async () => { - nock(serverUrl) - .post(`/api/apps/${appId}/actors/PongGame/connection-token`) - .reply(500, { message: "mint exploded" }); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, + status: 500, + response: { message: "mint exploded" }, + }); const onError = vi.fn(); - const base44 = createClient({ serverUrl, appId, token: "tok", options: { onError } }); + const base44 = createClient({ + serverUrl, + appId, + token: "tok", + options: { onError }, + }); base44.actors.PongGame("r1").connect({ id: "c1" }); await expect(sockets[0].urlProvider()).rejects.toThrow("mint exploded"); expect(onError).toHaveBeenCalledTimes(1); @@ -647,20 +725,34 @@ describe("Actors Module — client wiring", () => { vi.stubGlobal("document", undefined); vi.stubGlobal("localStorage", undefined); + mockHttp({ + method: "post", + url: `${serverUrl}/api/apps/${appId}/analytics/track/batch`, + status: 200, + response: {}, + inspect: async (request) => { + expect((await request.json()).events[0].event_name).toBe( + "__initialization_event__", + ); + }, + }); const seen: unknown[] = []; - nock(serverUrl) - .post(`/api/apps/${appId}/actors/PongGame/connection-token`) - .times(2) - .reply(function () { - seen.push(this.req.headers["x-base44-anonymous-id"]); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, + times: 2, + respond: function (request) { + seen.push(request.headers.get("x-base44-anonymous-id")); return [ 200, { - websocket_url: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", + websocket_url: + "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", token: "jwt.min.ted", }, ]; - }); + }, + }); const base44 = createClient({ serverUrl, appId }); base44.actors.PongGame("r1").connect({ id: "c1" }); @@ -681,25 +773,30 @@ describe("Actors Module — client wiring", () => { vi.stubGlobal("document", undefined); vi.stubGlobal("localStorage", undefined); - const scope = nock(serverUrl, { - reqheaders: { authorization: "Bearer tok" }, - badheaders: ["x-base44-anonymous-id"], - }) - .post(`/api/apps/${appId}/actors/PongGame/connection-token`) - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, + ...{ + reqheaders: { authorization: "Bearer tok" }, + badheaders: ["x-base44-anonymous-id"], + }, + status: 200, + response: { websocket_url: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", token: "jwt.min.ted", - }); + }, + }); const base44 = createClient({ serverUrl, appId, token: "tok" }); base44.actors.PongGame("r1").connect({ id: "c1" }); - await expect(sockets[0].urlProvider()).resolves.toContain("token=jwt.min.ted"); - expect(scope.isDone()).toBe(true); + await expect(sockets[0].urlProvider()).resolves.toContain( + "token=jwt.min.ted", + ); base44.cleanup(); }); test('actorsTransport: "proxy" dials the proxy without minting', async () => { - // no nock intercept: any HTTP call would throw + // no HTTP handler: any HTTP call would throw const base44 = createClient({ serverUrl, appId, diff --git a/tests/unit/agents.test.ts b/tests/unit/agents.test.ts index d74c1a21..d5650b9c 100644 --- a/tests/unit/agents.test.ts +++ b/tests/unit/agents.test.ts @@ -1,22 +1,18 @@ +import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; import { createClient } from "../../src/index.ts"; describe("Agents Module", () => { let base44: ReturnType; - let scope: nock.Scope; const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; beforeEach(() => { base44 = createClient({ serverUrl, appId }); - scope = nock(serverUrl); - nock.disableNetConnect(); }); afterEach(() => { - nock.cleanAll(); - nock.enableNetConnect(); + base44.cleanup(); }); describe("getConversations", () => { @@ -25,7 +21,12 @@ describe("Agents Module", () => { { id: "conv-1", agent_name: "support", messages: [] }, { id: "conv-2", agent_name: "sales", messages: [] }, ]; - scope.get(`/api/apps/${appId}/agents/conversations`).reply(200, mockConversations); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/agents/conversations`, + status: 200, + response: mockConversations, + }); const result = await base44.agents.getConversations(); expect(result).toEqual(mockConversations); @@ -34,8 +35,17 @@ describe("Agents Module", () => { describe("getConversation", () => { test("should fetch a specific conversation", async () => { - const mockConversation = { id: "conv-1", agent_name: "support", messages: [] }; - scope.get(`/api/apps/${appId}/agents/conversations/conv-1`).reply(200, mockConversation); + const mockConversation = { + id: "conv-1", + agent_name: "support", + messages: [], + }; + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/agents/conversations/conv-1`, + status: 200, + response: mockConversation, + }); const result = await base44.agents.getConversation("conv-1"); expect(result).toEqual(mockConversation); @@ -45,20 +55,43 @@ describe("Agents Module", () => { describe("createConversation", () => { test("should create a conversation", async () => { const created = { id: "conv-new", agent_name: "support", messages: [] }; - scope.post(`/api/apps/${appId}/agents/conversations`).reply(200, created); - - const result = await base44.agents.createConversation({ agent_name: "support" }); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/agents/conversations`, + status: 200, + body: { agent_name: "support" }, + response: created, + }); + + const result = await base44.agents.createConversation({ + agent_name: "support", + }); expect(result).toEqual(created); }); }); describe("addMessage", () => { test("should post to v2 endpoint", async () => { - const conversation = { id: "conv-1", agent_name: "support", messages: [] } as any; + const conversation = { + id: "conv-1", + agent_name: "support", + messages: [], + } as any; const response = { id: "msg-1", role: "assistant", content: "Hello!" }; - scope.post(`/api/apps/${appId}/agents/conversations/v2/conv-1/messages`).reply(200, response); - - const result = await base44.agents.addMessage(conversation, { role: "user", content: "Hi" }); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/agents/conversations/v2/conv-1/messages`, + status: 200, + body: { role: "user", content: "Hi" }, + response: response, + }); + + const result = await base44.agents.addMessage(conversation, { + role: "user", + content: "Hi", + }); expect(result).toEqual(response); }); }); @@ -66,36 +99,50 @@ describe("Agents Module", () => { describe("getWhatsAppConnectURL", () => { test("should return URL without token when no auth", () => { const url = base44.agents.getWhatsAppConnectURL("support"); - expect(url).toBe(`${serverUrl}/api/apps/${appId}/agents/support/whatsapp`); + expect(url).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/whatsapp`, + ); }); test("should include token when authenticated", () => { const authed = createClient({ serverUrl, appId, token: "test-token" }); const url = authed.agents.getWhatsAppConnectURL("support"); - expect(url).toBe(`${serverUrl}/api/apps/${appId}/agents/support/whatsapp?token=test-token`); + expect(url).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/whatsapp?token=test-token`, + ); + authed.cleanup(); }); test("should encode agent name", () => { const url = base44.agents.getWhatsAppConnectURL("my agent"); - expect(url).toBe(`${serverUrl}/api/apps/${appId}/agents/my%20agent/whatsapp`); + expect(url).toBe( + `${serverUrl}/api/apps/${appId}/agents/my%20agent/whatsapp`, + ); }); }); describe("getTelegramConnectURL", () => { test("should return URL without token when no auth", () => { const url = base44.agents.getTelegramConnectURL("support"); - expect(url).toBe(`${serverUrl}/api/apps/${appId}/agents/support/telegram`); + expect(url).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/telegram`, + ); }); test("should include token when authenticated", () => { const authed = createClient({ serverUrl, appId, token: "test-token" }); const url = authed.agents.getTelegramConnectURL("support"); - expect(url).toBe(`${serverUrl}/api/apps/${appId}/agents/support/telegram?token=test-token`); + expect(url).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/telegram?token=test-token`, + ); + authed.cleanup(); }); test("should encode agent name", () => { const url = base44.agents.getTelegramConnectURL("my agent"); - expect(url).toBe(`${serverUrl}/api/apps/${appId}/agents/my%20agent/telegram`); + expect(url).toBe( + `${serverUrl}/api/apps/${appId}/agents/my%20agent/telegram`, + ); }); }); }); diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 48e61f60..8838897d 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -8,7 +8,8 @@ import { import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; import { resetAnalyticsSessionContext } from "../../src/modules/analytics.ts"; import { InternalAuthModule, User } from "../../src/modules/auth.types.ts"; -import { AxiosInstance } from "axios"; +import { http, HttpResponse } from "msw"; +import { server } from "../mocks/server"; describe("Analytics Module", () => { let base44: ReturnType; @@ -22,22 +23,10 @@ describe("Analytics Module", () => { const serverUrl = "https://api.base44.com"; beforeEach(() => { - vi.mock("../../src/utils/axios-client.ts", () => ({ - createAxiosClient: vi.fn().mockImplementation( - () => - ({ - // `setToken` and `logout` write through to these, so the mock needs - // them present per instance. - defaults: { headers: { common: {} as Record } }, - request: vi.fn().mockResolvedValue({ - status: 200, - data: { - message: "success", - }, - }), - } as unknown as AxiosInstance) - ), - })); + server.use( + http.post(`${serverUrl}/api/apps/${appId}/analytics/track/batch`, () => HttpResponse.json({message: "success"})), + http.get(`${serverUrl}/api/apps/${appId}/entities/User/me`, () => HttpResponse.json({id: "test-user-id"})), + ); sharedState = getSharedInstance("analytics", () => ({ requestsQueue: [], isProcessing: false, @@ -66,9 +55,16 @@ describe("Analytics Module", () => { }); }); - afterEach(() => { + afterEach(async () => { + // Let real intercepted requests and the processor finish before resetting shared state. + await vi.waitFor(() => expect(sharedState?.isProcessing).toBe(false), {timeout: 5000}); + vi.useRealTimers(); + vi.restoreAllMocks(); vi.clearAllMocks(); base44.cleanup(); + // cleanup flips the shared flag but does not cancel a processor throttle timer. + // Drain that existing timer before another test starts a new processor. + await new Promise((resolve) => setTimeout(resolve, 1100)); vi.unstubAllGlobals(); sharedState = null; }); @@ -218,7 +214,6 @@ describe("Analytics Module", () => { }); test("should track multiple events", async () => { - vi.useFakeTimers(); for (let i = 0; i < 5; i++) { base44.analytics.track({ eventName: `test-event ${i}` }); @@ -226,16 +221,12 @@ describe("Analytics Module", () => { expect(sharedState?.isProcessing).toBe(true); expect(sharedState?.requestsQueue.length).toBe(4); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(2); + await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(2), {timeout: 2500}); // add another event while processing to mix things up base44.analytics.track({ eventName: `test-event 5` }); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(1); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(0); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.isProcessing).toBe(false); + await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(1), {timeout: 2500}); + await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(0), {timeout: 2500}); + await vi.waitFor(() => expect(sharedState?.isProcessing).toBe(false), {timeout: 2500}); }); }); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index ac4eb6c8..b3847380 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -1,5 +1,5 @@ +import { mockHttp } from "../mocks/http"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import nock from "nock"; import { Base44Error, createClient } from "../../src/index.ts"; describe("App module", () => { @@ -8,21 +8,22 @@ describe("App module", () => { const token = "user-token-456"; const publicSettingsPath = `/api/apps/public/prod/public-settings/by-id/${appId}`; let base44: ReturnType; - let scope: nock.Scope; beforeEach(() => { base44 = createClient({ serverUrl, appId, token }); - scope = nock(serverUrl); }); afterEach(() => { - nock.cleanAll(); + base44.cleanup(); }); test("getPublicSettings returns the app id and its access policy", async () => { - scope - .get(publicSettingsPath) - .reply(200, { id: appId, public_settings: "public_without_login" }); + mockHttp({ + method: "get", + url: serverUrl + publicSettingsPath, + status: 200, + response: { id: appId, public_settings: "public_without_login" }, + }); const settings = await base44.app.getPublicSettings(); @@ -30,31 +31,32 @@ describe("App module", () => { id: appId, public_settings: "public_without_login", }); - expect(scope.isDone()).toBe(true); }); test("getPublicSettings authenticates with the client's token, so callers never handle it", async () => { - scope - .get(publicSettingsPath) - .matchHeader("Authorization", `Bearer ${token}`) - .reply(200, { id: appId, public_settings: "private_with_login" }); + mockHttp({ + method: "get", + url: serverUrl + publicSettingsPath, + headers: [["Authorization", `Bearer ${token}`]], + status: 200, + response: { id: appId, public_settings: "private_with_login" }, + }); await base44.app.getPublicSettings(); - - expect(scope.isDone()).toBe(true); }); test("getPublicSettings sends no Authorization header for an anonymous client", async () => { const anonymous = createClient({ serverUrl, appId }); - scope - .get(publicSettingsPath) - .matchHeader("Authorization", (value) => value === undefined) - .reply(200, { id: appId, public_settings: "public_without_login" }); + mockHttp({ + method: "get", + url: serverUrl + publicSettingsPath, + headers: [["Authorization", (value) => value === undefined]], + status: 200, + response: { id: appId, public_settings: "public_without_login" }, + }); await anonymous.app.getPublicSettings(); - - expect(scope.isDone()).toBe(true); }); test.each([ @@ -63,9 +65,12 @@ describe("App module", () => { ])( "getPublicSettings surfaces a 403 %s as a Base44Error carrying the reason", async (reason) => { - scope - .get(publicSettingsPath) - .reply(403, { extra_data: { app_id: appId, reason } }); + mockHttp({ + method: "get", + url: serverUrl + publicSettingsPath, + status: 403, + response: { extra_data: { app_id: appId, reason } }, + }); const error = await base44.app .getPublicSettings() @@ -74,6 +79,6 @@ describe("App module", () => { expect(error).toBeInstanceOf(Base44Error); expect(error.status).toBe(403); expect(error.data.extra_data.reason).toBe(reason); - } + }, ); }); diff --git a/tests/unit/auth-registration.test.ts b/tests/unit/auth-registration.test.ts new file mode 100644 index 00000000..7f5c436a --- /dev/null +++ b/tests/unit/auth-registration.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createClient } from "../../src/index"; +import { mockHttp } from "../mocks/http"; + +describe("Auth registration and password recovery HTTP contracts", () => { + const serverUrl = "https://api.base44.com"; + const appId = "registration-test"; + let client: ReturnType; + beforeEach(() => { + client = createClient({ serverUrl, appId }); + }); + afterEach(() => client.cleanup()); + test("register sends supplied fields without renaming or dropping challenge/referral data", async () => { + const payload = { + email: "new@example.test", + password: "test-only-password", + turnstile_token: "challenge", + referral_code: "referral", + }; + mockHttp({ + method: "post", + url: `${serverUrl}/api/apps/${appId}/auth/register`, + body: payload, + response: { message: "Verification required" }, + }); + expect(await client.auth.register(payload)).toEqual({ + message: "Verification required", + }); + }); + test("registration rejection preserves the platform error response", async () => { + const payload = { + email: "existing@example.test", + password: "test-only-password", + }; + mockHttp({ + method: "post", + url: `${serverUrl}/api/apps/${appId}/auth/register`, + body: payload, + status: 400, + response: { detail: "Registration rejected" }, + }); + await expect(client.auth.register(payload)).rejects.toMatchObject({ + status: 400, + message: "Registration rejected", + }); + }); + test("password reset request sends only the email", async () => { + mockHttp({ + method: "post", + url: `${serverUrl}/api/apps/${appId}/auth/reset-password-request`, + body: { email: "reset@example.test" }, + response: { message: "Request accepted" }, + }); + expect( + await client.auth.resetPasswordRequest("reset@example.test"), + ).toEqual({ message: "Request accepted" }); + }); + test("password reset maps SDK camelCase to wire snake_case", async () => { + mockHttp({ + method: "post", + url: `${serverUrl}/api/apps/${appId}/auth/reset-password`, + body: { + reset_token: "test-reset-token", + new_password: "test-new-password", + }, + response: { message: "Password reset" }, + }); + expect( + await client.auth.resetPassword({ + resetToken: "test-reset-token", + newPassword: "test-new-password", + }), + ).toEqual({ message: "Password reset" }); + }); + test("invalid reset token retains the error status and message", async () => { + mockHttp({ + method: "post", + url: `${serverUrl}/api/apps/${appId}/auth/reset-password`, + body: { reset_token: "expired", new_password: "test-new-password" }, + status: 400, + response: { detail: "Reset token expired" }, + }); + await expect( + client.auth.resetPassword({ + resetToken: "expired", + newPassword: "test-new-password", + }), + ).rejects.toMatchObject({ status: 400, message: "Reset token expired" }); + }); +}); diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index c79df86b..f58d4723 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -1,27 +1,33 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; -import { getSharedInstance } from '../../src/utils/sharedInstance.ts'; - -describe('Auth Module', () => { +import { mockHttp } from "../mocks/http"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { createClient as newClient } from "../../src/index.ts"; +import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; + +const clients = []; +const createClient = (...args) => { + const client = newClient(...args); + clients.push(client); + return client; +}; + +describe("Auth Module", () => { let base44; - let scope; - const appId = 'test-app-id'; - const serverUrl = 'https://api.base44.com'; - const appBaseUrl = 'https://api.base44.com'; + const appId = "test-app-id"; + const serverUrl = "https://api.base44.com"; + const appBaseUrl = "https://api.base44.com"; beforeEach(() => { // Mock window.addEventListener and document for analytics module - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { if (!window.addEventListener) { window.addEventListener = vi.fn(); window.removeEventListener = vi.fn(); } } - if (typeof document === 'undefined') { + if (typeof document === "undefined") { global.document = { - referrer: '', - visibilityState: 'visible' + referrer: "", + visibilityState: "visible", }; } @@ -31,72 +37,65 @@ describe('Auth Module', () => { appId, appBaseUrl, }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on('no match', (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log('Headers:', req.getHeaders()); - }); }); - + afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners('no match'); - nock.enableNetConnect(); - + for (const client of clients.splice(0)) client.cleanup(); // Clean up localStorage if it exists - if (typeof window !== 'undefined' && window.localStorage) { + if (typeof window !== "undefined" && window.localStorage) { window.localStorage.clear(); } }); - - describe('me()', () => { - test('should fetch current user information', async () => { + + describe("me()", () => { + test("should fetch current user information", async () => { const mockUser = { - id: 'user-123', - email: 'test@example.com', - name: 'Test User', - role: 'user' + id: "user-123", + email: "test@example.com", + name: "Test User", + role: "user", }; - + // Mock the API response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(200, mockUser); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 200, + response: mockUser, + }); + // Call the API const result = await base44.auth.me(); - + // Verify the response - auth methods return data directly, not wrapped expect(result).toEqual(mockUser); - expect(result.id).toBe('user-123'); - expect(result.email).toBe('test@example.com'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle authentication errors', async () => { + expect(result.id).toBe("user-123"); + expect(result.email).toBe("test@example.com"); + }); + + test("should handle authentication errors", async () => { // Mock the API error response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(401, { detail: 'Unauthorized' }); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 401, + response: { detail: "Unauthorized" }, + }); + // Call the API and expect an error await expect(base44.auth.me()).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('shares one in-flight request between concurrent callers', async () => { - const mockUser = { id: 'user-123', email: 'test@example.com' }; + test("shares one in-flight request between concurrent callers", async () => { + const mockUser = { id: "user-123", email: "test@example.com" }; // A single interceptor: a second GET would hit disableNetConnect and throw. - scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, mockUser); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 200, + response: mockUser, + }); const [first, second] = await Promise.all([ base44.auth.me(), @@ -105,190 +104,225 @@ describe('Auth Module', () => { expect(first).toEqual(mockUser); expect(second).toEqual(mockUser); - expect(scope.isDone()).toBe(true); }); - test('does not reuse a resolved user across separate calls', async () => { - scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'user-1' }); - scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'user-2' }); + test("does not reuse a resolved user across separate calls", async () => { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 200, + response: { id: "user-1" }, + }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 200, + response: { id: "user-2" }, + }); const first = await base44.auth.me(); const second = await base44.auth.me(); // Sharing is limited to the in-flight window; identity is never cached. - expect(first.id).toBe('user-1'); - expect(second.id).toBe('user-2'); - expect(scope.isDone()).toBe(true); + expect(first.id).toBe("user-1"); + expect(second.id).toBe("user-2"); }); - test('does not retain a rejected request', async () => { - const mockUser = { id: 'user-123' }; - scope.get(`/api/apps/${appId}/entities/User/me`).reply(401, { detail: 'Unauthorized' }); - scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, mockUser); + test("does not retain a rejected request", async () => { + const mockUser = { id: "user-123" }; + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 401, + response: { detail: "Unauthorized" }, + }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 200, + response: mockUser, + }); await expect(base44.auth.me()).rejects.toThrow(); await expect(base44.auth.me()).resolves.toEqual(mockUser); - - expect(scope.isDone()).toBe(true); }); - test('setToken() drops an in-flight request from the previous identity', async () => { - scope - .get(`/api/apps/${appId}/entities/User/me`) - .delay(50) - .reply(200, { id: 'anonymous' }); - scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'logged-in' }); + test("setToken() drops an in-flight request from the previous identity", async () => { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + delayMs: 50, + status: 200, + response: { id: "anonymous" }, + }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 200, + response: { id: "logged-in" }, + }); const beforeLogin = base44.auth.me(); - base44.auth.setToken('new-access-token', false); + base44.auth.setToken("new-access-token", false); const afterLogin = await base44.auth.me(); // The call made after the identity change must not resolve into the // request that was already in flight for the anonymous one. - expect(afterLogin.id).toBe('logged-in'); - await expect(beforeLogin).resolves.toEqual({ id: 'anonymous' }); - expect(scope.isDone()).toBe(true); + expect(afterLogin.id).toBe("logged-in"); + await expect(beforeLogin).resolves.toEqual({ id: "anonymous" }); }); - test('a superseded request does not retire the current one', async () => { - scope - .get(`/api/apps/${appId}/entities/User/me`) - .delay(50) - .reply(200, { id: 'anonymous' }); + test("a superseded request does not retire the current one", async () => { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + delayMs: 50, + status: 200, + response: { id: "anonymous" }, + }); // One interceptor for the post-login identity: if the settling anonymous // request retires it, the third caller issues a second GET and this test // hits disableNetConnect. - scope - .get(`/api/apps/${appId}/entities/User/me`) - .delay(50) - .reply(200, { id: 'logged-in' }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + delayMs: 50, + status: 200, + response: { id: "logged-in" }, + }); const beforeLogin = base44.auth.me(); - base44.auth.setToken('new-access-token', false); + base44.auth.setToken("new-access-token", false); const afterLogin = base44.auth.me(); // Let the anonymous request settle while the post-login one is still in // flight, then join it. - await expect(beforeLogin).resolves.toEqual({ id: 'anonymous' }); + await expect(beforeLogin).resolves.toEqual({ id: "anonymous" }); const joined = base44.auth.me(); - expect(await afterLogin).toEqual({ id: 'logged-in' }); - expect(await joined).toEqual({ id: 'logged-in' }); - expect(scope.isDone()).toBe(true); + expect(await afterLogin).toEqual({ id: "logged-in" }); + expect(await joined).toEqual({ id: "logged-in" }); }); - test('setToken() clears the analytics session context', () => { - const analyticsState = getSharedInstance('analytics', () => ({})); - analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' }; + test("setToken() clears the analytics session context", () => { + const analyticsState = getSharedInstance("analytics", () => ({})); + analyticsState.sessionContext = { + user_id: "anonymous-user", + session_id: "s1", + }; - base44.auth.setToken('new-access-token', false); + base44.auth.setToken("new-access-token", false); expect(analyticsState.sessionContext).toBeNull(); }); }); - describe('updateMe()', () => { - test('should update current user data', async () => { + describe("updateMe()", () => { + test("should update current user data", async () => { const updateData = { - name: 'Updated Name', - email: 'updated@example.com' + name: "Updated Name", + email: "updated@example.com", }; - + const updatedUser = { - id: 'user-123', + id: "user-123", ...updateData, - role: 'user' + role: "user", }; - + // Mock the API response - scope.put(`/api/apps/${appId}/entities/User/me`, updateData) - .reply(200, updatedUser); - + mockHttp({ + method: "put", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + body: updateData, + status: 200, + response: updatedUser, + }); + // Call the API const result = await base44.auth.updateMe(updateData); - + // Verify the response - auth methods return data directly, not wrapped expect(result).toEqual(updatedUser); - expect(result.name).toBe('Updated Name'); - expect(result.email).toBe('updated@example.com'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle validation errors', async () => { + expect(result.name).toBe("Updated Name"); + expect(result.email).toBe("updated@example.com"); + }); + + test("should handle validation errors", async () => { const invalidData = { - email: 'invalid-email' + email: "invalid-email", }; - + // Mock the API error response - scope.put(`/api/apps/${appId}/entities/User/me`, invalidData) - .reply(400, { detail: 'Invalid email format' }); - + mockHttp({ + method: "put", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + body: invalidData, + status: 400, + response: { detail: "Invalid email format" }, + }); + // Call the API and expect an error await expect(base44.auth.updateMe(invalidData)).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); }); - - describe('login()', () => { - test('should throw error when not in browser environment', () => { + + describe("login()", () => { + test("should throw error when not in browser environment", () => { // Mock window as undefined to simulate non-browser environment const originalWindow = global.window; delete global.window; - + expect(() => { - base44.auth.redirectToLogin('/dashboard'); - }).toThrow('Login method can only be used in a browser environment'); - + base44.auth.redirectToLogin("/dashboard"); + }).toThrow("Login method can only be used in a browser environment"); + // Restore window global.window = originalWindow; }); - - test('should redirect to login page with correct URL in browser environment', () => { + + test("should redirect to login page with correct URL in browser environment", () => { // Mock window object - const mockLocation = { href: '' }; + const mockLocation = { href: "" }; const originalWindow = global.window; global.window = { - location: mockLocation + location: mockLocation, }; - const nextUrl = 'https://example.com/dashboard'; + const nextUrl = "https://example.com/dashboard"; base44.auth.redirectToLogin(nextUrl); // Verify the redirect URL was set correctly expect(mockLocation.href).toBe( - `${appBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}` + `${appBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}`, ); // Restore window global.window = originalWindow; }); - - test('should use current URL when nextUrl is not provided', () => { + + test("should use current URL when nextUrl is not provided", () => { // Mock window object - const currentUrl = 'https://example.com/current-page'; + const currentUrl = "https://example.com/current-page"; const mockLocation = { href: currentUrl }; const originalWindow = global.window; global.window = { - location: mockLocation + location: mockLocation, }; base44.auth.redirectToLogin(); // Verify the redirect URL uses current URL expect(mockLocation.href).toBe( - `${appBaseUrl}/login?from_url=${encodeURIComponent(currentUrl)}` + `${appBaseUrl}/login?from_url=${encodeURIComponent(currentUrl)}`, ); // Restore window global.window = originalWindow; }); - test('should use appBaseUrl for login redirect when provided', () => { - const customAppBaseUrl = 'https://custom-app.example.com'; + test("should use appBaseUrl for login redirect when provided", () => { + const customAppBaseUrl = "https://custom-app.example.com"; const clientWithCustomUrl = createClient({ serverUrl, appId, @@ -297,24 +331,24 @@ describe('Auth Module', () => { // Mock window.location const originalWindow = global.window; - const mockLocation = { href: '' }; + const mockLocation = { href: "" }; global.window = { - location: mockLocation + location: mockLocation, }; - const nextUrl = 'https://example.com/dashboard'; + const nextUrl = "https://example.com/dashboard"; clientWithCustomUrl.auth.redirectToLogin(nextUrl); // Verify the redirect URL uses the custom appBaseUrl expect(mockLocation.href).toBe( - `${customAppBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}` + `${customAppBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}`, ); // Restore window global.window = originalWindow; }); - test('should use relative URL for login redirect when appBaseUrl is not provided', () => { + test("should use relative URL for login redirect when appBaseUrl is not provided", () => { // Create a client without appBaseUrl const clientWithoutAppBaseUrl = createClient({ serverUrl, @@ -323,118 +357,134 @@ describe('Auth Module', () => { // Mock window.location const originalWindow = global.window; - const mockLocation = { href: '', origin: 'https://current-app.com' }; + const mockLocation = { href: "", origin: "https://current-app.com" }; global.window = { - location: mockLocation + location: mockLocation, }; - const nextUrl = 'https://example.com/dashboard'; + const nextUrl = "https://example.com/dashboard"; clientWithoutAppBaseUrl.auth.redirectToLogin(nextUrl); // Verify the redirect URL uses a relative path (no appBaseUrl prefix) expect(mockLocation.href).toBe( - `/login?from_url=${encodeURIComponent(nextUrl)}` + `/login?from_url=${encodeURIComponent(nextUrl)}`, ); // Restore window global.window = originalWindow; }); }); - - describe('logout()', () => { - test('should remove token from axios headers', async () => { + + describe("logout()", () => { + test("should remove token from axios headers", async () => { // Set a token first - base44.auth.setToken('test-token', false); - + base44.auth.setToken("test-token", false); + // Mock the API response for me() call - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', 'Bearer test-token') - .reply(200, { id: 'user-123', email: 'test@example.com' }); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + headers: [["Authorization", "Bearer test-token"]], + status: 200, + response: { id: "user-123", email: "test@example.com" }, + }); + // Verify token is set by making a request await base44.auth.me(); - expect(scope.isDone()).toBe(true); - + // Call logout base44.auth.logout(); - + // Mock another me() call to verify no Authorization header is sent - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', (val) => !val) // Should not have Authorization header - .reply(401, { detail: 'Unauthorized' }); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + headers: [["Authorization", (val) => !val]], + status: 401, + response: { detail: "Unauthorized" }, + }); + // Verify no Authorization header is sent after logout (should throw 401) await expect(base44.auth.me()).rejects.toThrow(); - expect(scope.isDone()).toBe(true); }); - - test('should remove token from localStorage in browser environment', async () => { + + test("should remove token from localStorage in browser environment", async () => { // Mock window and localStorage const mockLocalStorage = { removeItem: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), - clear: vi.fn() + clear: vi.fn(), }; const originalWindow = global.window; global.window = { localStorage: mockLocalStorage, location: { - reload: vi.fn() - } + reload: vi.fn(), + }, }; - + // Set a token to localStorage first - base44.auth.setToken('test-token', true); - expect(mockLocalStorage.setItem).toHaveBeenCalledWith('base44_access_token', 'test-token'); - + base44.auth.setToken("test-token", true); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "base44_access_token", + "test-token", + ); + // Call logout base44.auth.logout(); - + // Verify token was removed from localStorage - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('base44_access_token'); - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('token'); - + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + "base44_access_token", + ); + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith("token"); + // Restore window global.window = originalWindow; }); - - test('should handle localStorage errors gracefully', async () => { + + test("should handle localStorage errors gracefully", async () => { // Mock window and localStorage with error const mockLocalStorage = { removeItem: vi.fn().mockImplementation(() => { - throw new Error('localStorage error'); - }) + throw new Error("localStorage error"); + }), }; - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); const originalWindow = global.window; global.window = { localStorage: mockLocalStorage, location: { - reload: vi.fn() - } + reload: vi.fn(), + }, }; - + // Call logout - should not throw base44.auth.logout(); - + // Verify error was logged - expect(consoleSpy).toHaveBeenCalledWith('Failed to remove token from localStorage:', expect.any(Error)); - + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to remove token from localStorage:", + expect.any(Error), + ); + // Restore consoleSpy.mockRestore(); global.window = originalWindow; }); - - test('should redirect to specified URL after logout', async () => { + + test("should redirect to specified URL after logout", async () => { // Mock window object - const mockLocation = { href: '' }; + const mockLocation = { href: "" }; const originalWindow = global.window; global.window = { - location: mockLocation + location: mockLocation, }; - const redirectUrl = 'https://example.com/logout-success'; + const redirectUrl = "https://example.com/logout-success"; base44.auth.logout(redirectUrl); // Verify redirect to server-side logout endpoint with from_url parameter @@ -444,350 +494,382 @@ describe('Auth Module', () => { // Restore window global.window = originalWindow; }); - - test('should redirect to logout endpoint when no redirect URL is provided', async () => { + + test("should redirect to logout endpoint when no redirect URL is provided", async () => { // Mock window object - const mockLocation = { href: 'https://example.com/current-page' }; + const mockLocation = { href: "https://example.com/current-page" }; const originalWindow = global.window; global.window = { - location: mockLocation + location: mockLocation, }; // Call logout without redirect URL base44.auth.logout(); // Verify redirect to server-side logout endpoint with current page as from_url - const expectedUrl = `${appBaseUrl}/api/apps/auth/logout?from_url=${encodeURIComponent('https://example.com/current-page')}`; + const expectedUrl = `${appBaseUrl}/api/apps/auth/logout?from_url=${encodeURIComponent("https://example.com/current-page")}`; expect(mockLocation.href).toBe(expectedUrl); // Restore window global.window = originalWindow; }); }); - - describe('setToken()', () => { - test('should set token in axios headers', async () => { - const token = 'test-access-token'; - + + describe("setToken()", () => { + test("should set token in axios headers", async () => { + const token = "test-access-token"; + base44.auth.setToken(token, false); - + // Mock the API response for me() call - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', `Bearer ${token}`) - .reply(200, { id: 'user-123', email: 'test@example.com' }); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + headers: [["Authorization", `Bearer ${token}`]], + status: 200, + response: { id: "user-123", email: "test@example.com" }, + }); + // Verify token is set by making a request await base44.auth.me(); - expect(scope.isDone()).toBe(true); }); - - test('should save token to localStorage when requested', () => { + + test("should save token to localStorage when requested", () => { // Mock window and localStorage const mockLocalStorage = { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn(), - clear: vi.fn() + clear: vi.fn(), }; const originalWindow = global.window; global.window = { - localStorage: mockLocalStorage + localStorage: mockLocalStorage, }; - - const token = 'test-access-token'; + + const token = "test-access-token"; base44.auth.setToken(token, true); - + // Verify token was saved to localStorage - expect(mockLocalStorage.setItem).toHaveBeenCalledWith('base44_access_token', token); - + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "base44_access_token", + token, + ); + // Restore window global.window = originalWindow; }); - - test('should not save token to localStorage when not requested', () => { + + test("should not save token to localStorage when not requested", () => { // Mock window and localStorage const mockLocalStorage = { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn(), - clear: vi.fn() + clear: vi.fn(), }; const originalWindow = global.window; global.window = { - localStorage: mockLocalStorage + localStorage: mockLocalStorage, }; - - const token = 'test-access-token'; + + const token = "test-access-token"; base44.auth.setToken(token, false); - + // Verify token was not saved to localStorage expect(mockLocalStorage.setItem).not.toHaveBeenCalled(); - + // Restore window global.window = originalWindow; }); - - test('should handle empty token gracefully', async () => { - base44.auth.setToken('', false); - + + test("should handle empty token gracefully", async () => { + base44.auth.setToken("", false); + // Mock the API response for me() call - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', (val) => !val) // Should not have Authorization header - .reply(401, { detail: 'Unauthorized' }); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + headers: [["Authorization", (val) => !val]], + status: 401, + response: { detail: "Unauthorized" }, + }); + // Verify no Authorization header is sent (should throw 401) await expect(base44.auth.me()).rejects.toThrow(); - expect(scope.isDone()).toBe(true); }); - - test('should handle localStorage errors gracefully', () => { + + test("should handle localStorage errors gracefully", () => { // Mock window and localStorage with error const mockLocalStorage = { setItem: vi.fn().mockImplementation(() => { - throw new Error('localStorage error'); - }) + throw new Error("localStorage error"); + }), }; - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); const originalWindow = global.window; global.window = { - localStorage: mockLocalStorage + localStorage: mockLocalStorage, }; - - const token = 'test-access-token'; + + const token = "test-access-token"; base44.auth.setToken(token, true); - + // Verify error was logged - expect(consoleSpy).toHaveBeenCalledWith('Failed to save token to localStorage:', expect.any(Error)); - + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to save token to localStorage:", + expect.any(Error), + ); + // Restore consoleSpy.mockRestore(); global.window = originalWindow; }); }); - - describe('loginViaEmailPassword()', () => { - test('should login successfully with email and password', async () => { + + describe("loginViaEmailPassword()", () => { + test("should login successfully with email and password", async () => { const loginData = { - email: 'test@example.com', - password: 'password123' + email: "test@example.com", + password: "password123", }; - + const mockResponse = { - access_token: 'test-access-token', + access_token: "test-access-token", user: { - id: 'user-123', - email: 'test@example.com', - name: 'Test User' - } + id: "user-123", + email: "test@example.com", + name: "Test User", + }, }; - + // Mock the API response - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(200, mockResponse); - + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/auth/login`, + body: loginData, + status: 200, + response: mockResponse, + }); + // Call the API const result = await base44.auth.loginViaEmailPassword( loginData.email, - loginData.password + loginData.password, ); - + // Verify the response - expect(result.access_token).toBe('test-access-token'); - expect(result.user.email).toBe('test@example.com'); - + expect(result.access_token).toBe("test-access-token"); + expect(result.user.email).toBe("test@example.com"); + // Verify token was set in axios headers by making a subsequent request - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', 'Bearer test-access-token') - .reply(200, { id: 'user-123', email: 'test@example.com' }); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + headers: [["Authorization", "Bearer test-access-token"]], + status: 200, + response: { id: "user-123", email: "test@example.com" }, + }); + await base44.auth.me(); - expect(scope.isDone()).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - - test('should login with turnstile token when provided', async () => { + + test("should login with turnstile token when provided", async () => { const loginData = { - email: 'test@example.com', - password: 'password123', - turnstile_token: 'turnstile-token-123' + email: "test@example.com", + password: "password123", + turnstile_token: "turnstile-token-123", }; - + const mockResponse = { - access_token: 'test-access-token', + access_token: "test-access-token", user: { - id: 'user-123', - email: 'test@example.com' - } + id: "user-123", + email: "test@example.com", + }, }; - + // Mock the API response - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(200, mockResponse); - + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/auth/login`, + body: loginData, + status: 200, + response: mockResponse, + }); + // Call the API const result = await base44.auth.loginViaEmailPassword( loginData.email, loginData.password, - loginData.turnstile_token + loginData.turnstile_token, ); - + // Verify the response - expect(result.access_token).toBe('test-access-token'); - + expect(result.access_token).toBe("test-access-token"); + // Verify token was set in axios headers by making a subsequent request - scope.get(`/api/apps/${appId}/entities/User/me`) - .matchHeader('Authorization', 'Bearer test-access-token') - .reply(200, { id: 'user-123', email: 'test@example.com' }); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + headers: [["Authorization", "Bearer test-access-token"]], + status: 200, + response: { id: "user-123", email: "test@example.com" }, + }); + await base44.auth.me(); - expect(scope.isDone()).toBe(true); }); - - test('should handle authentication errors and logout', async () => { + + test("should handle authentication errors and logout", async () => { const loginData = { - email: 'test@example.com', - password: 'wrongpassword' + email: "test@example.com", + password: "wrongpassword", }; - + // Mock the API error response - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(401, { detail: 'Invalid credentials' }); - + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/auth/login`, + body: loginData, + status: 401, + response: { detail: "Invalid credentials" }, + }); + // Set a token first to test logout - base44.auth.setToken('existing-token', false); - + base44.auth.setToken("existing-token", false); + // Call the API and expect an error await expect( - base44.auth.loginViaEmailPassword(loginData.email, loginData.password) + base44.auth.loginViaEmailPassword(loginData.email, loginData.password), ).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - - test('should handle network errors', async () => { + + test("should handle network errors", async () => { const loginData = { - email: 'test@example.com', - password: 'password123' + email: "test@example.com", + password: "password123", }; - + // Mock network error - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .replyWithError('Network error'); - + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/auth/login`, + body: loginData, + networkError: true, + }); + // Call the API and expect an error await expect( - base44.auth.loginViaEmailPassword(loginData.email, loginData.password) + base44.auth.loginViaEmailPassword(loginData.email, loginData.password), ).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); }); - - describe('isAuthenticated()', () => { - test('should return true when token is valid', async () => { + + describe("isAuthenticated()", () => { + test("should return true when token is valid", async () => { const mockUser = { - id: 'user-123', - email: 'test@example.com' + id: "user-123", + email: "test@example.com", }; - + // Mock the API response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(200, mockUser); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 200, + response: mockUser, + }); + // Call the API const result = await base44.auth.isAuthenticated(); - + // Verify the response expect(result).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - - test('should return false when token is invalid', async () => { + + test("should return false when token is invalid", async () => { // Mock the API error response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(401, { detail: 'Unauthorized' }); - + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + status: 401, + response: { detail: "Unauthorized" }, + }); + // Call the API const result = await base44.auth.isAuthenticated(); - + // Verify the response expect(result).toBe(false); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - - test('should return false on network errors', async () => { + + test("should return false on network errors", async () => { // Mock network error - scope.get(`/api/apps/${appId}/entities/User/me`) - .replyWithError('Network error'); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/me`, + networkError: true, + }); // Call the API const result = await base44.auth.isAuthenticated(); // Verify the response expect(result).toBe(false); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); }); - describe('loginWithProvider()', () => { - test('should redirect to google login URL by default', () => { + describe("loginWithProvider()", () => { + test("should redirect to google login URL by default", () => { const originalWindow = global.window; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; const win = { location: mockLocation }; win.parent = win; // not in iframe global.window = win; - base44.auth.loginWithProvider('google', '/dashboard'); + base44.auth.loginWithProvider("google", "/dashboard"); expect(mockLocation.href).toContain(`${appBaseUrl}/api/apps/auth/login?`); expect(mockLocation.href).toContain(`app_id=${appId}`); - expect(mockLocation.href).toContain('from_url='); + expect(mockLocation.href).toContain("from_url="); global.window = originalWindow; }); - test('should include provider path for non-google providers', () => { + test("should include provider path for non-google providers", () => { const originalWindow = global.window; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; const win = { location: mockLocation }; win.parent = win; global.window = win; - base44.auth.loginWithProvider('microsoft', '/dashboard'); + base44.auth.loginWithProvider("microsoft", "/dashboard"); - expect(mockLocation.href).toContain('/api/apps/auth/microsoft/login?'); + expect(mockLocation.href).toContain("/api/apps/auth/microsoft/login?"); global.window = originalWindow; }); - test('should use SSO URL structure for sso provider', () => { + test("should use SSO URL structure for sso provider", () => { const originalWindow = global.window; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; const win = { location: mockLocation }; win.parent = win; global.window = win; - base44.auth.loginWithProvider('sso', '/dashboard'); + base44.auth.loginWithProvider("sso", "/dashboard"); expect(mockLocation.href).toContain(`/api/apps/${appId}/auth/sso/login?`); global.window = originalWindow; }); - test('should use popup when inside an iframe', () => { + test("should use popup when inside an iframe", () => { const originalWindow = global.window; const mockPopup = { closed: false, close: vi.fn() }; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; // Simulate iframe: window.parent !== window const parentWindow = {}; global.window = { @@ -802,28 +884,28 @@ describe('Auth Module', () => { removeEventListener: vi.fn(), }; - base44.auth.loginWithProvider('google', '/dashboard'); + base44.auth.loginWithProvider("google", "/dashboard"); // Should NOT have redirected - expect(mockLocation.href).toBe(''); + expect(mockLocation.href).toBe(""); // Should have opened a popup expect(global.window.open).toHaveBeenCalledTimes(1); const openCall = global.window.open.mock.calls[0]; - expect(openCall[0]).toContain('popup_origin='); - expect(openCall[1]).toBe('base44_auth'); + expect(openCall[0]).toContain("popup_origin="); + expect(openCall[1]).toBe("base44_auth"); global.window = originalWindow; }); - test('should not use popup when not inside an iframe', () => { + test("should not use popup when not inside an iframe", () => { const originalWindow = global.window; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; // window.parent === window (not in iframe) const win = { location: mockLocation, open: vi.fn() }; win.parent = win; global.window = win; - base44.auth.loginWithProvider('google', '/dashboard'); + base44.auth.loginWithProvider("google", "/dashboard"); // Should have redirected directly expect(mockLocation.href).toContain(`${appBaseUrl}/api/apps/auth/login?`); @@ -833,9 +915,9 @@ describe('Auth Module', () => { global.window = originalWindow; }); - test('should handle popup being blocked by browser', () => { + test("should handle popup being blocked by browser", () => { const originalWindow = global.window; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; const parentWindow = {}; global.window = { location: mockLocation, @@ -851,16 +933,16 @@ describe('Auth Module', () => { // Should not throw expect(() => { - base44.auth.loginWithProvider('google', '/dashboard'); + base44.auth.loginWithProvider("google", "/dashboard"); }).not.toThrow(); global.window = originalWindow; }); - test('should redirect on postMessage with valid token from popup', () => { + test("should redirect on postMessage with valid token from popup", () => { const originalWindow = global.window; const mockPopup = { closed: false, close: vi.fn() }; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; const parentWindow = {}; let messageHandler; global.window = { @@ -872,33 +954,33 @@ describe('Auth Module', () => { outerHeight: 768, open: vi.fn().mockReturnValue(mockPopup), addEventListener: vi.fn((event, handler) => { - if (event === 'message') messageHandler = handler; + if (event === "message") messageHandler = handler; }), removeEventListener: vi.fn(), }; - base44.auth.loginWithProvider('google', '/callback'); + base44.auth.loginWithProvider("google", "/callback"); // Simulate postMessage from popup messageHandler({ - origin: 'https://myapp.com', + origin: "https://myapp.com", source: mockPopup, - data: { access_token: 'test-token-123', is_new_user: true }, + data: { access_token: "test-token-123", is_new_user: true }, }); // Should redirect with token params - expect(mockLocation.href).toContain('access_token=test-token-123'); - expect(mockLocation.href).toContain('is_new_user=true'); + expect(mockLocation.href).toContain("access_token=test-token-123"); + expect(mockLocation.href).toContain("is_new_user=true"); // Popup should be closed expect(mockPopup.close).toHaveBeenCalled(); global.window = originalWindow; }); - test('should ignore postMessage from wrong origin', () => { + test("should ignore postMessage from wrong origin", () => { const originalWindow = global.window; const mockPopup = { closed: false, close: vi.fn() }; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; const parentWindow = {}; let messageHandler; global.window = { @@ -910,31 +992,31 @@ describe('Auth Module', () => { outerHeight: 768, open: vi.fn().mockReturnValue(mockPopup), addEventListener: vi.fn((event, handler) => { - if (event === 'message') messageHandler = handler; + if (event === "message") messageHandler = handler; }), removeEventListener: vi.fn(), }; - base44.auth.loginWithProvider('google', '/callback'); + base44.auth.loginWithProvider("google", "/callback"); // Simulate postMessage from wrong origin messageHandler({ - origin: 'https://evil.com', + origin: "https://evil.com", source: mockPopup, - data: { access_token: 'stolen-token' }, + data: { access_token: "stolen-token" }, }); // Should NOT have redirected - expect(mockLocation.href).toBe(''); + expect(mockLocation.href).toBe(""); expect(mockPopup.close).not.toHaveBeenCalled(); global.window = originalWindow; }); - test('should ignore postMessage from wrong source', () => { + test("should ignore postMessage from wrong source", () => { const originalWindow = global.window; const mockPopup = { closed: false, close: vi.fn() }; - const mockLocation = { href: '', origin: 'https://myapp.com' }; + const mockLocation = { href: "", origin: "https://myapp.com" }; const parentWindow = {}; let messageHandler; global.window = { @@ -946,25 +1028,25 @@ describe('Auth Module', () => { outerHeight: 768, open: vi.fn().mockReturnValue(mockPopup), addEventListener: vi.fn((event, handler) => { - if (event === 'message') messageHandler = handler; + if (event === "message") messageHandler = handler; }), removeEventListener: vi.fn(), }; - base44.auth.loginWithProvider('google', '/callback'); + base44.auth.loginWithProvider("google", "/callback"); // Simulate postMessage from correct origin but different source messageHandler({ - origin: 'https://myapp.com', + origin: "https://myapp.com", source: {}, // not the popup - data: { access_token: 'stolen-token' }, + data: { access_token: "stolen-token" }, }); // Should NOT have redirected - expect(mockLocation.href).toBe(''); + expect(mockLocation.href).toBe(""); expect(mockPopup.close).not.toHaveBeenCalled(); global.window = originalWindow; }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 5ba2eba2..150ba7ac 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -1,50 +1,70 @@ -import { createClient, createClientFromRequest } from '../../src/index.ts'; -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; +import { mockHttp } from "../mocks/http"; +import { + createClient as newClient, + createClientFromRequest as newClientFromRequest, +} from "../../src/index.ts"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; + +const clients = []; +const createClient = (...args) => { + const client = newClient(...args); + clients.push(client); + return client; +}; +const createClientFromRequest = (...args) => { + const client = newClientFromRequest(...args); + clients.push(client); + return client; +}; +afterEach(() => { + for (const client of clients.splice(0)) client.cleanup(); +}); -describe('Client Creation', () => { - test('should create a client with default options', () => { +describe("Client Creation", () => { + test("should create a client with default options", () => { const client = createClient({ - appId: 'test-app-id', + appId: "test-app-id", }); - + expect(client).toBeDefined(); expect(client.entities).toBeDefined(); expect(client.integrations).toBeDefined(); expect(client.auth).toBeDefined(); expect(client.analytics).toBeDefined(); - + const config = client.getConfig(); - expect(config.appId).toBe('test-app-id'); - expect(config.serverUrl).toBe('https://base44.app'); + expect(config.appId).toBe("test-app-id"); + expect(config.serverUrl).toBe("https://base44.app"); expect(config.requiresAuth).toBe(false); - + // Should throw error when accessing asServiceRole without service token - expect(() => client.asServiceRole).toThrow('Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.'); + expect(() => client.asServiceRole).toThrow( + "Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.", + ); }); - - test('should create a client with custom options', () => { + + test("should create a client with custom options", () => { const client = createClient({ - appId: 'test-app-id', - serverUrl: 'https://custom-server.com', + appId: "test-app-id", + serverUrl: "https://custom-server.com", requiresAuth: true, - token: 'test-token', + token: "test-token", }); - + expect(client).toBeDefined(); - + const config = client.getConfig(); - expect(config.appId).toBe('test-app-id'); - expect(config.serverUrl).toBe('https://custom-server.com'); + expect(config.appId).toBe("test-app-id"); + expect(config.serverUrl).toBe("https://custom-server.com"); expect(config.requiresAuth).toBe(true); }); - test('should create a client with service token', () => { + test("should create a client with service token", () => { const client = createClient({ - appId: 'test-app-id', - serviceToken: 'service-token-123', + appId: "test-app-id", + serviceToken: "service-token-123", }); - + expect(client).toBeDefined(); expect(client.entities).toBeDefined(); expect(client.integrations).toBeDefined(); @@ -57,11 +77,11 @@ describe('Client Creation', () => { expect(client.asServiceRole.auth).toBeUndefined(); }); - test('should create a client with both user token and service token', () => { + test("should create a client with both user token and service token", () => { const client = createClient({ - appId: 'test-app-id', - token: 'user-token-123', - serviceToken: 'service-token-123', + appId: "test-app-id", + token: "user-token-123", + serviceToken: "service-token-123", requiresAuth: true, }); @@ -75,55 +95,54 @@ describe('Client Creation', () => { expect(client.asServiceRole.functions).toBeDefined(); expect(client.asServiceRole.auth).toBeUndefined(); }); - }); -describe('appBaseUrl Normalization', () => { - test('should use appBaseUrl when provided as a string', () => { - const customAppBaseUrl = 'https://custom-app.example.com'; +describe("appBaseUrl Normalization", () => { + test("should use appBaseUrl when provided as a string", () => { + const customAppBaseUrl = "https://custom-app.example.com"; const client = createClient({ - appId: 'test-app-id', + appId: "test-app-id", appBaseUrl: customAppBaseUrl, }); // Mock window.location const originalWindow = global.window; - const mockLocation = { href: '', origin: 'https://current-app.com' }; + const mockLocation = { href: "", origin: "https://current-app.com" }; global.window = { - location: mockLocation + location: mockLocation, }; - const nextUrl = 'https://example.com/dashboard'; + const nextUrl = "https://example.com/dashboard"; client.auth.redirectToLogin(nextUrl); // Verify the redirect URL uses the custom appBaseUrl expect(mockLocation.href).toBe( - `${customAppBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}` + `${customAppBaseUrl}/login?from_url=${encodeURIComponent(nextUrl)}`, ); // Restore window global.window = originalWindow; }); - test('should normalize appBaseUrl to empty string when not provided', () => { + test("should normalize appBaseUrl to empty string when not provided", () => { const client = createClient({ - appId: 'test-app-id', + appId: "test-app-id", // appBaseUrl not provided }); // Mock window.location const originalWindow = global.window; - const mockLocation = { href: '', origin: 'https://current-app.com' }; + const mockLocation = { href: "", origin: "https://current-app.com" }; global.window = { - location: mockLocation + location: mockLocation, }; - const nextUrl = 'https://example.com/dashboard'; + const nextUrl = "https://example.com/dashboard"; client.auth.redirectToLogin(nextUrl); // Verify the redirect URL uses empty string (relative path) expect(mockLocation.href).toBe( - `/login?from_url=${encodeURIComponent(nextUrl)}` + `/login?from_url=${encodeURIComponent(nextUrl)}`, ); // Restore window @@ -131,219 +150,203 @@ describe('appBaseUrl Normalization', () => { }); }); -describe('createClientFromRequest', () => { - test('should create client from request with all headers', () => { +describe("createClientFromRequest", () => { + test("should create client from request with all headers", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'Bearer user-token-123', - 'Base44-Service-Authorization': 'Bearer service-token-123', - 'Base44-App-Id': 'test-app-id', - 'Base44-Api-Url': 'https://custom-server.com' + Authorization: "Bearer user-token-123", + "Base44-Service-Authorization": "Bearer service-token-123", + "Base44-App-Id": "test-app-id", + "Base44-Api-Url": "https://custom-server.com", }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); expect(client.entities).toBeDefined(); expect(client.integrations).toBeDefined(); expect(client.auth).toBeDefined(); expect(client.asServiceRole).toBeDefined(); - + const config = client.getConfig(); - expect(config.appId).toBe('test-app-id'); - expect(config.serverUrl).toBe('https://custom-server.com'); + expect(config.appId).toBe("test-app-id"); + expect(config.serverUrl).toBe("https://custom-server.com"); }); - test('should create client from request with minimal headers', () => { + test("should create client from request with minimal headers", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Base44-App-Id': 'minimal-app-id' + "Base44-App-Id": "minimal-app-id", }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); const config = client.getConfig(); - expect(config.appId).toBe('minimal-app-id'); - expect(config.serverUrl).toBe('https://base44.app'); // Default value + expect(config.appId).toBe("minimal-app-id"); + expect(config.serverUrl).toBe("https://base44.app"); // Default value }); - test('should create client with only user token', () => { + test("should create client with only user token", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'Bearer user-only-token', - 'Base44-App-Id': 'user-app-id' + Authorization: "Bearer user-only-token", + "Base44-App-Id": "user-app-id", }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); expect(client.auth).toBeDefined(); // Should throw error when accessing asServiceRole without service token - expect(() => client.asServiceRole).toThrow('Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.'); + expect(() => client.asServiceRole).toThrow( + "Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.", + ); }); - test('should create client with only service token', () => { + test("should create client with only service token", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Base44-Service-Authorization': 'Bearer service-only-token', - 'Base44-App-Id': 'service-app-id' + "Base44-Service-Authorization": "Bearer service-only-token", + "Base44-App-Id": "service-app-id", }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); expect(client.auth).toBeDefined(); expect(client.asServiceRole).toBeDefined(); }); - test('should throw error when Base44-App-Id header is missing', () => { + test("should throw error when Base44-App-Id header is missing", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'Bearer some-token' + Authorization: "Bearer some-token", }; return headers[name] || null; - } - } + }, + }, }; expect(() => createClientFromRequest(mockRequest)).toThrow( - 'Base44-App-Id header is required, but is was not found on the request' + "Base44-App-Id header is required, but is was not found on the request", ); }); - test('should throw error for malformed authorization headers', () => { + test("should throw error for malformed authorization headers", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'InvalidFormat', - 'Base44-Service-Authorization': 'AlsoInvalid', - 'Base44-App-Id': 'test-app-id' + Authorization: "InvalidFormat", + "Base44-Service-Authorization": "AlsoInvalid", + "Base44-App-Id": "test-app-id", }; return headers[name] || null; - } - } + }, + }, }; // Should throw error for malformed headers instead of continuing silently - expect(() => createClientFromRequest(mockRequest)).toThrow('Invalid authorization header format. Expected "Bearer "'); + expect(() => createClientFromRequest(mockRequest)).toThrow( + 'Invalid authorization header format. Expected "Bearer "', + ); }); - test('should throw error for empty authorization headers', () => { + test("should throw error for empty authorization headers", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': '', - 'Base44-Service-Authorization': '', - 'Base44-App-Id': 'test-app-id' + Authorization: "", + "Base44-Service-Authorization": "", + "Base44-App-Id": "test-app-id", }; - return headers[name] === '' ? '' : headers[name] || null; - } - } + return headers[name] === "" ? "" : headers[name] || null; + }, + }, }; // Should throw error for empty headers instead of continuing silently - expect(() => createClientFromRequest(mockRequest)).toThrow('Invalid authorization header format. Expected "Bearer "'); + expect(() => createClientFromRequest(mockRequest)).toThrow( + 'Invalid authorization header format. Expected "Bearer "', + ); }); - test('should propagate Base44-State header when present', () => { + test("should propagate Base44-State header when present", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Base44-App-Id': 'test-app-id', - 'Base44-State': '192.168.1.100' + "Base44-App-Id": "test-app-id", + "Base44-State": "192.168.1.100", }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); const config = client.getConfig(); - expect(config.appId).toBe('test-app-id'); + expect(config.appId).toBe("test-app-id"); }); - test('should work without Base44-State header', () => { + test("should work without Base44-State header", () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Base44-App-Id': 'test-app-id' + "Base44-App-Id": "test-app-id", }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); - + expect(client).toBeDefined(); const config = client.getConfig(); - expect(config.appId).toBe('test-app-id'); + expect(config.appId).toBe("test-app-id"); }); }); +describe("Service Role Authorization Headers", () => { + const appId = "test-app-id"; + const serverUrl = "https://api.base44.com"; -describe('Service Role Authorization Headers', () => { - - let scope; - const appId = 'test-app-id'; - const serverUrl = 'https://api.base44.com'; - - beforeEach(() => { - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on('no match', (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log('Headers:', req.getHeaders()); - }); - }); - - afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners('no match'); - nock.enableNetConnect(); - }); + test("should use user token for regular client operations and service token for service role operations", async () => { + const userToken = "user-token-123"; + const serviceToken = "service-token-456"; - test('should use user token for regular client operations and service token for service role operations', async () => { - const userToken = 'user-token-123'; - const serviceToken = 'service-token-456'; - const client = createClient({ serverUrl, appId, @@ -352,26 +355,31 @@ describe('Service Role Authorization Headers', () => { }); // Mock user entities request (should use user token) - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('Authorization', `Bearer ${userToken}`) - .reply(200, { items: [], total: 0 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + headers: [["Authorization", `Bearer ${userToken}`]], + status: 200, + response: { items: [], total: 0 }, + }); // Mock service role entities request (should use service token) - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('Authorization', `Bearer ${serviceToken}`) - .reply(200, { items: [], total: 0 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + headers: [["Authorization", `Bearer ${serviceToken}`]], + status: 200, + response: { items: [], total: 0 }, + }); // Make requests await client.entities.Todo.list(); await client.asServiceRole.entities.Todo.list(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('should use service token for service role entities operations', async () => { - const serviceToken = 'service-token-only-123'; - + test("should use service token for service role entities operations", async () => { + const serviceToken = "service-token-only-123"; + const client = createClient({ serverUrl, appId, @@ -379,24 +387,25 @@ describe('Service Role Authorization Headers', () => { }); // Mock service role entities request - scope.get(`/api/apps/${appId}/entities/User/123`) - .matchHeader('Authorization', `Bearer ${serviceToken}`) - .reply(200, { id: '123', name: 'Test User' }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/123`, + headers: [["Authorization", `Bearer ${serviceToken}`]], + status: 200, + response: { id: "123", name: "Test User" }, + }); // Make request - const result = await client.asServiceRole.entities.User.get('123'); + const result = await client.asServiceRole.entities.User.get("123"); // Verify response - expect(result.id).toBe('123'); - expect(result.name).toBe('Test User'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.id).toBe("123"); + expect(result.name).toBe("Test User"); }); - test('should use service token for service role integrations operations', async () => { - const serviceToken = 'service-token-integration-456'; - + test("should use service token for service role integrations operations", async () => { + const serviceToken = "service-token-integration-456"; + const client = createClient({ serverUrl, appId, @@ -404,28 +413,30 @@ describe('Service Role Authorization Headers', () => { }); // Mock service role integrations request - scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`) - .matchHeader('Authorization', `Bearer ${serviceToken}`) - .reply(200, { success: true, messageId: '123' }); + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, + headers: [["Authorization", `Bearer ${serviceToken}`]], + status: 200, + response: { success: true, messageId: "123" }, + }); // Make request - const result = await client.asServiceRole.integrations.Core.SendEmail({ - to: 'test@example.com', - subject: 'Test', - body: 'Test message' + const result = await client.asServiceRole.integrations.Core.SendEmail({ + to: "test@example.com", + subject: "Test", + body: "Test message", }); // Verify response expect(result.success).toBe(true); - expect(result.messageId).toBe('123'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.messageId).toBe("123"); }); - test('should use service token for service role functions operations', async () => { - const serviceToken = 'service-token-functions-789'; - + test("should use service token for service role functions operations", async () => { + const serviceToken = "service-token-functions-789"; + const client = createClient({ serverUrl, appId, @@ -433,26 +444,28 @@ describe('Service Role Authorization Headers', () => { }); // Mock service role functions request - scope.post(`/api/apps/${appId}/functions/testFunction`, { param: 'test' }) - .matchHeader('Authorization', `Bearer ${serviceToken}`) - .reply(200, { result: 'function executed' }); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/testFunction`, + body: { param: "test" }, + headers: [["Authorization", `Bearer ${serviceToken}`]], + status: 200, + response: { result: "function executed" }, + }); // Make request - const result = await client.asServiceRole.functions.invoke('testFunction', { - param: 'test' + const result = await client.asServiceRole.functions.invoke("testFunction", { + param: "test", }); // Verify response - expect(result.data.result).toBe('function executed'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.data.result).toBe("function executed"); }); - test('should use user token for regular operations when both tokens are present', async () => { - const userToken = 'user-token-regular-123'; - const serviceToken = 'service-token-regular-456'; - + test("should use user token for regular operations when both tokens are present", async () => { + const userToken = "user-token-regular-123"; + const serviceToken = "service-token-regular-456"; + const client = createClient({ serverUrl, appId, @@ -461,229 +474,260 @@ describe('Service Role Authorization Headers', () => { }); // Mock regular user entities request (should use user token) - scope.get(`/api/apps/${appId}/entities/Task`) - .matchHeader('Authorization', `Bearer ${userToken}`) - .reply(200, { items: [{ id: 'task1', title: 'User Task' }], total: 1 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Task`, + headers: [["Authorization", `Bearer ${userToken}`]], + status: 200, + response: { items: [{ id: "task1", title: "User Task" }], total: 1 }, + }); // Mock regular integrations request (should use user token) - scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`) - .matchHeader('Authorization', `Bearer ${userToken}`) - .reply(200, { success: true, messageId: 'email123' }); + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, + headers: [["Authorization", `Bearer ${userToken}`]], + status: 200, + response: { success: true, messageId: "email123" }, + }); // Make requests using regular client (not service role) const taskResult = await client.entities.Task.list(); const emailResult = await client.integrations.Core.SendEmail({ - to: 'user@example.com', - subject: 'User Test', - body: 'User message' + to: "user@example.com", + subject: "User Test", + body: "User message", }); // Verify responses - expect(taskResult.items[0].title).toBe('User Task'); + expect(taskResult.items[0].title).toBe("User Task"); expect(emailResult.success).toBe(true); - expect(emailResult.messageId).toBe('email123'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(emailResult.messageId).toBe("email123"); }); - test('should work without authorization header when no tokens are provided', async () => { + test("should work without authorization header when no tokens are provided", async () => { const client = createClient({ serverUrl, appId, }); // Mock request without authorization header - scope.get(`/api/apps/${appId}/entities/PublicData`) - .matchHeader('Authorization', (val) => !val) // Should not have Authorization header - .reply(200, { items: [{ id: 'public1', data: 'public' }], total: 1 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/PublicData`, + headers: [["Authorization", (val) => !val]], + status: 200, + response: { items: [{ id: "public1", data: "public" }], total: 1 }, + }); // Make request const result = await client.entities.PublicData.list(); // Verify response - expect(result.items[0].data).toBe('public'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.items[0].data).toBe("public"); }); - test('should propagate Base44-State header in API requests when created from request', async () => { - const clientIp = '192.168.1.100'; - + test("should propagate Base44-State header in API requests when created from request", async () => { + const clientIp = "192.168.1.100"; + const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'Bearer user-token-123', - 'Base44-App-Id': appId, - 'Base44-Api-Url': serverUrl, - 'Base44-State': clientIp + Authorization: "Bearer user-token-123", + "Base44-App-Id": appId, + "Base44-Api-Url": serverUrl, + "Base44-State": clientIp, }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); // Mock entities request and verify Base44-State header is present - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('Base44-State', clientIp) - .matchHeader('Authorization', 'Bearer user-token-123') - .reply(200, { items: [], total: 0 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + headers: [ + ["Base44-State", clientIp], + ["Authorization", "Bearer user-token-123"], + ], + status: 200, + response: { items: [], total: 0 }, + }); // Make request await client.entities.Todo.list(); // Verify all mocks were called (including header match) - expect(scope.isDone()).toBe(true); }); - test('should propagate X-Data-Env header on user-scoped API requests when created from request', async () => { + test("should propagate X-Data-Env header on user-scoped API requests when created from request", async () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'Bearer user-token-123', - 'Base44-App-Id': appId, - 'Base44-Api-Url': serverUrl, - 'X-Data-Env': 'dev' + Authorization: "Bearer user-token-123", + "Base44-App-Id": appId, + "Base44-Api-Url": serverUrl, + "X-Data-Env": "dev", }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); // The user-scoped client (not asServiceRole) must still carry the data env // so test-mode function callbacks hit test data, not production. - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('X-Data-Env', 'dev') - .matchHeader('Authorization', 'Bearer user-token-123') - .reply(200, { items: [], total: 0 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + headers: [ + ["X-Data-Env", "dev"], + ["Authorization", "Bearer user-token-123"], + ], + status: 200, + response: { items: [], total: 0 }, + }); await client.entities.Todo.list(); - - expect(scope.isDone()).toBe(true); }); - test('should not forward an X-Data-Env value outside the dev/prod set', async () => { + test("should not forward an X-Data-Env value outside the dev/prod set", async () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'Bearer user-token-123', - 'Base44-App-Id': appId, - 'Base44-Api-Url': serverUrl, - 'X-Data-Env': 'evil' + Authorization: "Bearer user-token-123", + "Base44-App-Id": appId, + "Base44-Api-Url": serverUrl, + "X-Data-Env": "evil", }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('X-Data-Env', (val) => !val) // arbitrary value must not be relayed - .matchHeader('Authorization', 'Bearer user-token-123') - .reply(200, { items: [], total: 0 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + headers: [ + ["X-Data-Env", (val) => !val], + ["Authorization", "Bearer user-token-123"], + ], + status: 200, + response: { items: [], total: 0 }, + }); await client.entities.Todo.list(); - - expect(scope.isDone()).toBe(true); }); - test('should not include X-Data-Env header when not present in original request', async () => { + test("should not include X-Data-Env header when not present in original request", async () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'Bearer user-token-123', - 'Base44-App-Id': appId, - 'Base44-Api-Url': serverUrl + Authorization: "Bearer user-token-123", + "Base44-App-Id": appId, + "Base44-Api-Url": serverUrl, }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('X-Data-Env', (val) => !val) // Should not have this header - .matchHeader('Authorization', 'Bearer user-token-123') - .reply(200, { items: [], total: 0 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + headers: [ + ["X-Data-Env", (val) => !val], + ["Authorization", "Bearer user-token-123"], + ], + status: 200, + response: { items: [], total: 0 }, + }); await client.entities.Todo.list(); - - expect(scope.isDone()).toBe(true); }); - test('should not include Base44-State header when not present in original request', async () => { + test("should not include Base44-State header when not present in original request", async () => { const mockRequest = { headers: { get: (name) => { const headers = { - 'Authorization': 'Bearer user-token-123', - 'Base44-App-Id': appId, - 'Base44-Api-Url': serverUrl + Authorization: "Bearer user-token-123", + "Base44-App-Id": appId, + "Base44-Api-Url": serverUrl, }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); // Mock entities request and verify Base44-State header is NOT present - scope.get(`/api/apps/${appId}/entities/Todo`) - .matchHeader('Base44-State', (val) => !val) // Should not have this header - .matchHeader('Authorization', 'Bearer user-token-123') - .reply(200, { items: [], total: 0 }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + headers: [ + ["Base44-State", (val) => !val], + ["Authorization", "Bearer user-token-123"], + ], + status: 200, + response: { items: [], total: 0 }, + }); // Make request await client.entities.Todo.list(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('should propagate Base44-State header in service role API requests', async () => { - const clientIp = '10.0.0.50'; - + test("should propagate Base44-State header in service role API requests", async () => { + const clientIp = "10.0.0.50"; + const mockRequest = { headers: { get: (name) => { const headers = { - 'Base44-Service-Authorization': 'Bearer service-token-123', - 'Base44-App-Id': appId, - 'Base44-Api-Url': serverUrl, - 'Base44-State': clientIp + "Base44-Service-Authorization": "Bearer service-token-123", + "Base44-App-Id": appId, + "Base44-Api-Url": serverUrl, + "Base44-State": clientIp, }; return headers[name] || null; - } - } + }, + }, }; const client = createClientFromRequest(mockRequest); // Mock service role entities request and verify Base44-State header is present - scope.get(`/api/apps/${appId}/entities/User/123`) - .matchHeader('Base44-State', clientIp) - .matchHeader('Authorization', 'Bearer service-token-123') - .reply(200, { id: '123', name: 'Test User' }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/User/123`, + headers: [ + ["Base44-State", clientIp], + ["Authorization", "Bearer service-token-123"], + ], + status: 200, + response: { id: "123", name: "Test User" }, + }); // Make request using service role - const result = await client.asServiceRole.entities.User.get('123'); + const result = await client.asServiceRole.entities.User.get("123"); // Verify response - expect(result.id).toBe('123'); + expect(result.id).toBe("123"); // Verify all mocks were called (including header match) - expect(scope.isDone()).toBe(true); }); - -}); \ No newline at end of file +}); diff --git a/tests/unit/connectors-proxy.test.ts b/tests/unit/connectors-proxy.test.ts index e2df1ed7..2a38a05e 100644 --- a/tests/unit/connectors-proxy.test.ts +++ b/tests/unit/connectors-proxy.test.ts @@ -1,5 +1,5 @@ +import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; import { createClient } from "../../src/index.ts"; describe("Connectors module – metered connector proxy", () => { @@ -7,17 +7,13 @@ describe("Connectors module – metered connector proxy", () => { const serverUrl = "https://base44.app"; const serviceToken = "service-token-123"; let base44: ReturnType; - let scope: nock.Scope; beforeEach(() => { base44 = createClient({ serverUrl, appId, serviceToken }); - scope = nock(serverUrl); - nock.disableNetConnect(); }); afterEach(() => { - nock.cleanAll(); - nock.enableNetConnect(); + base44.cleanup(); }); const proxyResponse = { @@ -31,12 +27,16 @@ describe("Connectors module – metered connector proxy", () => { test("posts the normalized request to the shared-connector proxy route", async () => { let received: any; - scope - .post(`/api/apps/${appId}/connectors/x/call`, (body) => { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/x/call`, + body: (body) => { received = body; return true; - }) - .reply(200, proxyResponse); + }, + status: 200, + response: proxyResponse, + }); await base44.asServiceRole.connectors.callApi("x", { method: "POST", @@ -56,15 +56,18 @@ describe("Connectors module – metered connector proxy", () => { test("percent-encodes the integration type so it stays on the connectors route", async () => { // The route carries the service-role token, so a runtime-built identifier // containing slashes must select a (nonexistent) connector, not another route. - scope - .post( - `/api/apps/${appId}/connectors/${encodeURIComponent("../evil/route")}/call` - ) - .reply(200, proxyResponse); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/connectors/${encodeURIComponent("../evil/route")}/call`, + status: 200, + response: proxyResponse, + }); const res = await base44.asServiceRole.connectors.callApi( "../evil/route" as any, - { path: "/x" } + { path: "/x" }, ); expect(res.success).toBe(true); @@ -74,13 +77,17 @@ describe("Connectors module – metered connector proxy", () => { // The payload is built field by field, so anything not explicitly forwarded // is silently dropped — which is what happened to `host` before this. const bodies: any[] = []; - scope - .post(`/api/apps/${appId}/connectors/googlemaps/call`, (body) => { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/googlemaps/call`, + body: (body) => { bodies.push(body); return true; - }) - .times(3) - .reply(200, proxyResponse); + }, + times: 3, + status: 200, + response: proxyResponse, + }); await base44.asServiceRole.connectors.callApi("googlemaps", { host: "places", @@ -102,15 +109,20 @@ describe("Connectors module – metered connector proxy", () => { }); test("maps a binary response to dataBase64 + contentType", async () => { - scope.post(`/api/apps/${appId}/connectors/googlemaps/call`).reply(200, { - success: true, - phase: "responded", - status_code: 200, - data: null, - data_base64: "iVBORw0KGgo=", - content_type: "image/png", - headers: {}, - credits_charged: 1, + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/googlemaps/call`, + status: 200, + response: { + success: true, + phase: "responded", + status_code: 200, + data: null, + data_base64: "iVBORw0KGgo=", + content_type: "image/png", + headers: {}, + credits_charged: 1, + }, }); const res = await base44.asServiceRole.connectors.callApi("googlemaps", { @@ -123,7 +135,12 @@ describe("Connectors module – metered connector proxy", () => { }); test("leaves dataBase64 and contentType null for a JSON response", async () => { - scope.post(`/api/apps/${appId}/connectors/x/call`).reply(200, proxyResponse); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/x/call`, + status: 200, + response: proxyResponse, + }); const res = await base44.asServiceRole.connectors.callApi("x", { path: "/2/users/me", @@ -135,12 +152,16 @@ describe("Connectors module – metered connector proxy", () => { test("defaults the method to GET", async () => { let received: any; - scope - .post(`/api/apps/${appId}/connectors/x/call`, (body) => { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/x/call`, + body: (body) => { received = body; return true; - }) - .reply(200, proxyResponse); + }, + status: 200, + response: proxyResponse, + }); await base44.asServiceRole.connectors.callApi("x", { path: "/2/users/me" }); @@ -151,12 +172,16 @@ describe("Connectors module – metered connector proxy", () => { // The server prices the merged query; dropping it client-side would make the // quoted price and the real request disagree. let received: any; - scope - .post(`/api/apps/${appId}/connectors/x/call`, (body) => { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/x/call`, + body: (body) => { received = body; return true; - }) - .reply(200, proxyResponse); + }, + status: 200, + response: proxyResponse, + }); await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets/search/recent", @@ -167,7 +192,12 @@ describe("Connectors module – metered connector proxy", () => { }); test("maps the proxy envelope to camelCase", async () => { - scope.post(`/api/apps/${appId}/connectors/x/call`).reply(200, proxyResponse); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/x/call`, + status: 200, + response: proxyResponse, + }); const res = await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets", @@ -184,13 +214,18 @@ describe("Connectors module – metered connector proxy", () => { test("returns an upstream error instead of throwing", async () => { // A provider 4xx is a normal outcome of a call Base44 completed (and billed), // so it must be inspectable rather than an exception. - scope.post(`/api/apps/${appId}/connectors/x/call`).reply(200, { - success: false, - phase: "responded", - status_code: 400, - data: { title: "Invalid Request" }, - headers: {}, - credits_charged: 3, + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/x/call`, + status: 200, + response: { + success: false, + phase: "responded", + status_code: 400, + data: { title: "Invalid Request" }, + headers: {}, + credits_charged: 3, + }, }); const res = await base44.asServiceRole.connectors.callApi("x", { @@ -209,31 +244,40 @@ describe("Connectors module – metered connector proxy", () => { test("rejects when Base44 itself refuses the call", async () => { // Credits exhausted is a Base44-side failure, not an upstream outcome. - scope.post(`/api/apps/${appId}/connectors/x/call`).reply(402, { - message: "You have reached the limit of integrations for this month", - extra_data: { reason: "integration_credits_limit_reached" }, + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/x/call`, + status: 402, + response: { + message: "You have reached the limit of integrations for this month", + extra_data: { reason: "integration_credits_limit_reached" }, + }, }); await expect( - base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }) + base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }), ).rejects.toMatchObject({ status: 402 }); }); test("a metered connector's token request surfaces the actionable refusal", async () => { // The backend's 403 detail names the proxy, which is what lets generated // code (and the model that wrote it) correct itself. - scope.get(`/api/apps/${appId}/external-auth/tokens/x`).reply( - 403, - { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/external-auth/tokens/x`, + status: 403, + response: { detail: "Connector 'x' is metered — raw access tokens are not available for it. " + `Call POST /api/apps/${appId}/connectors/x/call instead.`, }, - { "X-Base44-Connector-Error": "metered_connector_requires_proxy" } - ); + responseHeaders: { + "X-Base44-Connector-Error": "metered_connector_requires_proxy", + }, + }); await expect( - base44.asServiceRole.connectors.getConnection("x") + base44.asServiceRole.connectors.getConnection("x"), ).rejects.toMatchObject({ status: 403, code: "metered_connector_requires_proxy", @@ -248,23 +292,28 @@ describe("Connectors module – metered connector proxy", () => { base44.asServiceRole.connectors.callApi("x", { method: method as any, path: "/2/tweets", - }) + }), ).rejects.toThrow( - "Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD" + "Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD", ); - } + }, ); test.each(["not_sent", "timed_out", "sent_unconfirmed"] as const)( "maps proxy phase %s when no upstream response is available", async (phase) => { - scope.post(`/api/apps/${appId}/connectors/x/call`).reply(200, { - success: false, - phase, - status_code: null, - data: { error: "request outcome unknown" }, - headers: {}, - credits_charged: phase === "not_sent" ? 0 : 3, + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/connectors/x/call`, + status: 200, + response: { + success: false, + phase, + status_code: null, + data: { error: "request outcome unknown" }, + headers: {}, + credits_charged: phase === "not_sent" ? 0 : 3, + }, }); const res = await base44.asServiceRole.connectors.callApi("x", { @@ -274,7 +323,7 @@ describe("Connectors module – metered connector proxy", () => { expect(res.phase).toBe(phase); expect(res.status).toBeNull(); expect(res.success).toBe(false); - } + }, ); test.each([ @@ -282,7 +331,7 @@ describe("Connectors module – metered connector proxy", () => { ["x", ""], ])("rejects a missing identifier or path (%s, %s)", async (type, path) => { await expect( - base44.asServiceRole.connectors.callApi(type, { path }) + base44.asServiceRole.connectors.callApi(type, { path }), ).rejects.toThrow(/required and must be a string/); }); }); diff --git a/tests/unit/connectors.test.ts b/tests/unit/connectors.test.ts index c5dabfc3..23ec250c 100644 --- a/tests/unit/connectors.test.ts +++ b/tests/unit/connectors.test.ts @@ -1,5 +1,7 @@ +import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; +import { http, HttpResponse } from "msw"; +import { server } from "../mocks/server"; import { createClient } from "../../src/index.ts"; import type { AppUserConnectorConnectionResponse } from "../../src/modules/connectors.types.ts"; @@ -8,94 +10,79 @@ describe("Connectors module – getConnection", () => { const serverUrl = "https://base44.app"; const serviceToken = "service-token-123"; let base44: ReturnType; - let scope: nock.Scope; + const tokensBase = `${serverUrl}/api/apps/${appId}/external-auth/tokens`; beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - serviceToken, - }); - scope = nock(serverUrl); + base44 = createClient({ serverUrl, appId, serviceToken }); }); afterEach(() => { - nock.cleanAll(); + base44.cleanup(); }); test("extracts accessToken and connectionConfig from API response", async () => { - const apiResponse = { - access_token: "oauth-token-abc123", - integration_type: "jira", - connection_config: { subdomain: "my-company" }, - }; - - scope - .get(`/api/apps/${appId}/external-auth/tokens/jira`) - .reply(200, apiResponse); - - const connection = await base44.asServiceRole.connectors.getConnection( - "jira" + server.use( + http.get(`${tokensBase}/jira`, () => + HttpResponse.json({ + access_token: "oauth-token-abc123", + integration_type: "jira", + connection_config: { subdomain: "my-company" }, + }), + ), ); + const connection = + await base44.asServiceRole.connectors.getConnection("jira"); + expect(connection).toBeDefined(); expect(connection.accessToken).toBe("oauth-token-abc123"); - expect(connection.connectionConfig).toEqual({ - subdomain: "my-company", - }); - expect(scope.isDone()).toBe(true); + expect(connection.connectionConfig).toEqual({ subdomain: "my-company" }); }); test("returns connectionConfig as null when API omits connection_config", async () => { - const apiResponse = { - access_token: "token-only", - integration_type: "slack", - }; - - scope - .get(`/api/apps/${appId}/external-auth/tokens/slack`) - .reply(200, apiResponse); - - const connection = await base44.asServiceRole.connectors.getConnection( - "slack" + server.use( + http.get(`${tokensBase}/slack`, () => + HttpResponse.json({ + access_token: "token-only", + integration_type: "slack", + }), + ), ); + const connection = + await base44.asServiceRole.connectors.getConnection("slack"); + expect(connection.accessToken).toBe("token-only"); expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); }); test("returns connectionConfig as null when API sends null connection_config", async () => { - const apiResponse = { - access_token: "token-only", - integration_type: "github", - connection_config: null, - }; - - scope - .get(`/api/apps/${appId}/external-auth/tokens/github`) - .reply(200, apiResponse); - - const connection = await base44.asServiceRole.connectors.getConnection( - "github" + server.use( + http.get(`${tokensBase}/github`, () => + HttpResponse.json({ + access_token: "token-only", + integration_type: "github", + connection_config: null, + }), + ), ); + const connection = + await base44.asServiceRole.connectors.getConnection("github"); + expect(connection.accessToken).toBe("token-only"); expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); }); test("throws when integrationType is empty string", async () => { await expect( - base44.asServiceRole.connectors.getConnection("") + base44.asServiceRole.connectors.getConnection(""), ).rejects.toThrow("Integration type is required and must be a string"); }); test("throws when integrationType is not a string", async () => { await expect( - base44.asServiceRole.connectors.getConnection( - null as unknown as string - ) + base44.asServiceRole.connectors.getConnection(null as unknown as string), ).rejects.toThrow("Integration type is required and must be a string"); }); }); @@ -105,7 +92,6 @@ describe("Connectors module – getWorkspaceConnection", () => { const serverUrl = "https://base44.app"; const serviceToken = "service-token-123"; let base44: ReturnType; - let scope: nock.Scope; beforeEach(() => { base44 = createClient({ @@ -113,11 +99,10 @@ describe("Connectors module – getWorkspaceConnection", () => { appId, serviceToken, }); - scope = nock(serverUrl); }); afterEach(() => { - nock.cleanAll(); + base44.cleanup(); }); test("extracts accessToken and connectionConfig from connectors endpoint", async () => { @@ -127,20 +112,24 @@ describe("Connectors module – getWorkspaceConnection", () => { connection_config: { subdomain: "xy12345.us-east-1" }, }; - scope - .get(`/api/apps/${appId}/external-auth/tokens/connectors/connector-abc`) - .reply(200, apiResponse); + mockHttp({ + method: "get", + url: + serverUrl + + `/api/apps/${appId}/external-auth/tokens/connectors/connector-abc`, + status: 200, + response: apiResponse, + }); const connection = await base44.asServiceRole.connectors.getWorkspaceConnection( - "connector-abc" + "connector-abc", ); expect(connection.accessToken).toBe("builder-oauth-token-xyz789"); expect(connection.connectionConfig).toEqual({ subdomain: "xy12345.us-east-1", }); - expect(scope.isDone()).toBe(true); }); test("returns connectionConfig as null when API omits connection_config", async () => { @@ -149,29 +138,32 @@ describe("Connectors module – getWorkspaceConnection", () => { integration_type: "databricks", }; - scope - .get(`/api/apps/${appId}/external-auth/tokens/connectors/conn-2`) - .reply(200, apiResponse); + mockHttp({ + method: "get", + url: + serverUrl + `/api/apps/${appId}/external-auth/tokens/connectors/conn-2`, + status: 200, + response: apiResponse, + }); const connection = await base44.asServiceRole.connectors.getWorkspaceConnection("conn-2"); expect(connection.accessToken).toBe("token-only"); expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); }); test("throws when connectorId is empty string", async () => { await expect( - base44.asServiceRole.connectors.getWorkspaceConnection("") + base44.asServiceRole.connectors.getWorkspaceConnection(""), ).rejects.toThrow("Connector ID is required and must be a string"); }); test("throws when connectorId is not a string", async () => { await expect( base44.asServiceRole.connectors.getWorkspaceConnection( - null as unknown as string - ) + null as unknown as string, + ), ).rejects.toThrow("Connector ID is required and must be a string"); }); }); @@ -181,7 +173,6 @@ describe("Connectors module – getCurrentAppUserConnection", () => { const serverUrl = "https://base44.app"; const serviceToken = "service-token-123"; let base44: ReturnType; - let scope: nock.Scope; beforeEach(() => { base44 = createClient({ @@ -189,11 +180,10 @@ describe("Connectors module – getCurrentAppUserConnection", () => { appId, serviceToken, }); - scope = nock(serverUrl); }); afterEach(() => { - nock.cleanAll(); + base44.cleanup(); }); test("extracts accessToken and connectionConfig from API response", async () => { @@ -203,13 +193,18 @@ describe("Connectors module – getCurrentAppUserConnection", () => { connection_config: { subdomain: "my-company" }, }; - scope - .get(`/api/apps/${appId}/app-user-auth/connectors/connector-1/token`) - .reply(200, apiResponse); + mockHttp({ + method: "get", + url: + serverUrl + + `/api/apps/${appId}/app-user-auth/connectors/connector-1/token`, + status: 200, + response: apiResponse, + }); const connection: AppUserConnectorConnectionResponse = await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-1" + "connector-1", ); expect(connection).toBeDefined(); @@ -217,7 +212,6 @@ describe("Connectors module – getCurrentAppUserConnection", () => { expect(connection.connectionConfig).toEqual({ subdomain: "my-company", }); - expect(scope.isDone()).toBe(true); }); test("returns connectionConfig as null when API omits connection_config", async () => { @@ -226,18 +220,22 @@ describe("Connectors module – getCurrentAppUserConnection", () => { integration_type: "slack", }; - scope - .get(`/api/apps/${appId}/app-user-auth/connectors/connector-2/token`) - .reply(200, apiResponse); + mockHttp({ + method: "get", + url: + serverUrl + + `/api/apps/${appId}/app-user-auth/connectors/connector-2/token`, + status: 200, + response: apiResponse, + }); const connection: AppUserConnectorConnectionResponse = await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-2" + "connector-2", ); expect(connection.accessToken).toBe("user-token-only"); expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); }); test("returns connectionConfig as null when API sends null connection_config", async () => { @@ -247,31 +245,35 @@ describe("Connectors module – getCurrentAppUserConnection", () => { connection_config: null, }; - scope - .get(`/api/apps/${appId}/app-user-auth/connectors/connector-3/token`) - .reply(200, apiResponse); + mockHttp({ + method: "get", + url: + serverUrl + + `/api/apps/${appId}/app-user-auth/connectors/connector-3/token`, + status: 200, + response: apiResponse, + }); const connection: AppUserConnectorConnectionResponse = await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-3" + "connector-3", ); expect(connection.accessToken).toBe("user-token-only"); expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); }); test("throws when connectorId is empty string", async () => { await expect( - base44.asServiceRole.connectors.getCurrentAppUserConnection("") + base44.asServiceRole.connectors.getCurrentAppUserConnection(""), ).rejects.toThrow("Connector ID is required and must be a string"); }); test("throws when connectorId is not a string", async () => { await expect( base44.asServiceRole.connectors.getCurrentAppUserConnection( - null as unknown as string - ) + null as unknown as string, + ), ).rejects.toThrow("Connector ID is required and must be a string"); }); }); diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts index ab34bea5..dfa3035f 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -1,12 +1,11 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; +import { mockHttp } from "../mocks/http"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { createClient } from "../../src/index.ts"; -describe('Custom Integrations Module', () => { +describe("Custom Integrations Module", () => { let base44: ReturnType; - let scope: nock.Scope; - const appId = 'test-app-id'; - const serverUrl = 'https://base44.app'; + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; beforeEach(() => { // Create a new client for each test @@ -14,198 +13,225 @@ describe('Custom Integrations Module', () => { serverUrl, appId, }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); }); afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); + base44.cleanup(); }); - test('custom.call() should convert camelCase params to snake_case for backend', async () => { - const slug = 'github'; - const operationId = 'get:/repos/{owner}/{repo}/issues'; - + test("custom.call() should convert camelCase params to snake_case for backend", async () => { + const slug = "github"; + const operationId = "get:/repos/{owner}/{repo}/issues"; + // SDK call uses camelCase (JS convention) const sdkParams = { - payload: { title: 'Test Issue' }, - pathParams: { owner: 'testuser', repo: 'testrepo' }, - queryParams: { state: 'open' }, + payload: { title: "Test Issue" }, + pathParams: { owner: "testuser", repo: "testrepo" }, + queryParams: { state: "open" }, }; // Backend expects snake_case (Python convention) const expectedBody = { - payload: { title: 'Test Issue' }, - path_params: { owner: 'testuser', repo: 'testrepo' }, - query_params: { state: 'open' }, + payload: { title: "Test Issue" }, + path_params: { owner: "testuser", repo: "testrepo" }, + query_params: { state: "open" }, }; const mockResponse = { success: true, status_code: 200, - data: { issues: [{ id: 1, title: 'Test Issue' }] }, + data: { issues: [{ id: 1, title: "Test Issue" }] }, }; - // Mock expects snake_case body (curly braces in operationId must be URL-encoded for nock matching) - const encodedOperationId = operationId.replace(/{/g, '%7B').replace(/}/g, '%7D'); - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, expectedBody) - .reply(200, mockResponse); + const encodedOperationId = operationId + .replace(/{/g, "%7B") + .replace(/}/g, "%7D"); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, + body: expectedBody, + status: 200, + response: mockResponse, + }); // SDK call uses camelCase - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + const result = await base44.integrations.custom.call( + slug, + operationId, + sdkParams, + ); // Verify the response expect(result.success).toBe(true); expect(result.status_code).toBe(200); expect(result.data.issues).toHaveLength(1); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('custom.call() should work with empty params', async () => { - const slug = 'github'; - const operationId = 'getAuthenticatedUser'; + test("custom.call() should work with empty params", async () => { + const slug = "github"; + const operationId = "getAuthenticatedUser"; const mockResponse = { success: true, status_code: 200, - data: { login: 'testuser', id: 123 }, + data: { login: "testuser", id: 123 }, }; // Mock the API response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) - .reply(200, mockResponse); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, + body: {}, + status: 200, + response: mockResponse, + }); // Call without params const result = await base44.integrations.custom.call(slug, operationId); // Verify the response expect(result.success).toBe(true); - expect(result.data.login).toBe('testuser'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.data.login).toBe("testuser"); }); - test('custom.call() should handle 404 error for non-existent integration', async () => { - const slug = 'nonexistent'; - const operationId = 'someEndpoint'; + test("custom.call() should handle 404 error for non-existent integration", async () => { + const slug = "nonexistent"; + const operationId = "someEndpoint"; // Mock a 404 error response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) - .reply(404, { + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, + body: {}, + status: 404, + response: { detail: `Custom integration '${slug}' not found in workspace`, - }); + }, + }); // Call the API and expect an error - await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + await expect( + base44.integrations.custom.call(slug, operationId), + ).rejects.toMatchObject({ status: 404, - name: 'Base44Error', + name: "Base44Error", }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('custom.call() should handle 404 error for non-existent operation', async () => { - const slug = 'github'; - const operationId = 'nonExistentOperation'; + test("custom.call() should handle 404 error for non-existent operation", async () => { + const slug = "github"; + const operationId = "nonExistentOperation"; // Mock a 404 error response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) - .reply(404, { + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, + body: {}, + status: 404, + response: { detail: `Operation '${operationId}' not found in integration '${slug}'`, - }); + }, + }); // Call the API and expect an error - await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + await expect( + base44.integrations.custom.call(slug, operationId), + ).rejects.toMatchObject({ status: 404, - name: 'Base44Error', + name: "Base44Error", }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('custom.call() should handle 502 error from external API', async () => { - const slug = 'github'; - const operationId = 'get:/repos/{owner}/{repo}/issues'; + test("custom.call() should handle 502 error from external API", async () => { + const slug = "github"; + const operationId = "get:/repos/{owner}/{repo}/issues"; // Mock a 502 error response (external API failure) - curly braces in operationId must be URL-encoded - const encodedOperationId = operationId.replace(/{/g, '%7B').replace(/}/g, '%7D'); - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, {}) - .reply(502, { - detail: 'Failed to connect to external API: Connection refused', - }); + const encodedOperationId = operationId + .replace(/{/g, "%7B") + .replace(/}/g, "%7D"); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, + body: {}, + status: 502, + response: { + detail: "Failed to connect to external API: Connection refused", + }, + }); // Call the API and expect an error - await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + await expect( + base44.integrations.custom.call(slug, operationId), + ).rejects.toMatchObject({ status: 502, - name: 'Base44Error', + name: "Base44Error", }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('custom.call() should throw error when slug is missing', async () => { + test("custom.call() should throw error when slug is missing", async () => { // @ts-expect-error Testing invalid input await expect(base44.integrations.custom.call()).rejects.toThrow( - 'Integration slug is required and cannot be empty' + "Integration slug is required and cannot be empty", ); }); - test('custom.call() should throw error when operationId is missing', async () => { + test("custom.call() should throw error when operationId is missing", async () => { // @ts-expect-error Testing invalid input - await expect(base44.integrations.custom.call('github')).rejects.toThrow( - 'Operation ID is required and cannot be empty' + await expect(base44.integrations.custom.call("github")).rejects.toThrow( + "Operation ID is required and cannot be empty", ); }); - test('custom.call() should throw error when slug is empty string', async () => { - await expect(base44.integrations.custom.call('', 'get:/repos/{owner}/{repo}/issues')).rejects.toThrow( - 'Integration slug is required and cannot be empty' - ); + test("custom.call() should throw error when slug is empty string", async () => { + await expect( + base44.integrations.custom.call("", "get:/repos/{owner}/{repo}/issues"), + ).rejects.toThrow("Integration slug is required and cannot be empty"); }); - test('custom.call() should throw error when slug is whitespace only', async () => { - await expect(base44.integrations.custom.call(' ', 'get:/repos/{owner}/{repo}/issues')).rejects.toThrow( - 'Integration slug is required and cannot be empty' - ); + test("custom.call() should throw error when slug is whitespace only", async () => { + await expect( + base44.integrations.custom.call( + " ", + "get:/repos/{owner}/{repo}/issues", + ), + ).rejects.toThrow("Integration slug is required and cannot be empty"); }); - test('custom.call() should throw error when operationId is empty string', async () => { - await expect(base44.integrations.custom.call('github', '')).rejects.toThrow( - 'Operation ID is required and cannot be empty' + test("custom.call() should throw error when operationId is empty string", async () => { + await expect(base44.integrations.custom.call("github", "")).rejects.toThrow( + "Operation ID is required and cannot be empty", ); }); - test('custom.call() should throw error when operationId is whitespace only', async () => { - await expect(base44.integrations.custom.call('github', ' \t\n ')).rejects.toThrow( - 'Operation ID is required and cannot be empty' - ); + test("custom.call() should throw error when operationId is whitespace only", async () => { + await expect( + base44.integrations.custom.call("github", " \t\n "), + ).rejects.toThrow("Operation ID is required and cannot be empty"); }); - test('custom.call() should handle large payloads', async () => { - const slug = 'myapi'; - const operationId = 'bulkCreate'; - + test("custom.call() should handle large payloads", async () => { + const slug = "myapi"; + const operationId = "bulkCreate"; + // Create a large payload with many items const largeArray = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Item ${i}`, - description: 'A'.repeat(100), + description: "A".repeat(100), metadata: { key: `value_${i}` }, })); - + const sdkParams = { payload: { items: largeArray }, }; @@ -217,58 +243,72 @@ describe('Custom Integrations Module', () => { }; // Mock the API response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) - .reply(200, mockResponse); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, + body: sdkParams, + status: 200, + response: mockResponse, + }); // Call the API with large payload - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + const result = await base44.integrations.custom.call( + slug, + operationId, + sdkParams, + ); // Verify the response expect(result.success).toBe(true); expect(result.data.created).toBe(1000); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('custom.call() should include custom headers in request', async () => { - const slug = 'myapi'; - const operationId = 'getData'; + test("custom.call() should include custom headers in request", async () => { + const slug = "myapi"; + const operationId = "getData"; const sdkParams = { - headers: { 'X-Custom-Header': 'custom-value' }, + headers: { "X-Custom-Header": "custom-value" }, }; const mockResponse = { success: true, status_code: 200, - data: { result: 'ok' }, + data: { result: "ok" }, }; // Mock the API response - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) - .reply(200, mockResponse); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, + body: sdkParams, + status: 200, + response: mockResponse, + }); // Call the API - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + const result = await base44.integrations.custom.call( + slug, + operationId, + sdkParams, + ); // Verify the response expect(result.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('custom.call() should pass through multiple headers', async () => { - const slug = 'myapi'; - const operationId = 'secureEndpoint'; + test("custom.call() should pass through multiple headers", async () => { + const slug = "myapi"; + const operationId = "secureEndpoint"; const sdkParams = { headers: { - 'X-API-Key': 'secret-key-123', - 'X-Request-ID': 'req-456', - 'Accept-Language': 'en-US', - 'X-Custom-Auth': 'Bearer token123', + "X-API-Key": "secret-key-123", + "X-Request-ID": "req-456", + "Accept-Language": "en-US", + "X-Custom-Auth": "Bearer token123", }, }; @@ -279,82 +319,105 @@ describe('Custom Integrations Module', () => { }; // Mock the API response - verify all headers are passed in the body - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) - .reply(200, mockResponse); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, + body: sdkParams, + status: 200, + response: mockResponse, + }); // Call the API - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + const result = await base44.integrations.custom.call( + slug, + operationId, + sdkParams, + ); // Verify the response expect(result.success).toBe(true); expect(result.data.authenticated).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - test('custom.call() should only include defined params in body', async () => { - const slug = 'github'; - const operationId = 'get:/users/{username}'; - + test("custom.call() should only include defined params in body", async () => { + const slug = "github"; + const operationId = "get:/users/{username}"; + // SDK call with only pathParams const sdkParams = { - pathParams: { username: 'octocat' }, + pathParams: { username: "octocat" }, }; // Expected body should only have path_params, not empty payload/query_params/headers const expectedBody = { - path_params: { username: 'octocat' }, + path_params: { username: "octocat" }, }; const mockResponse = { success: true, status_code: 200, - data: { login: 'octocat' }, + data: { login: "octocat" }, }; - // Curly braces in operationId must be URL-encoded for nock matching - const encodedOperationId = operationId.replace(/{/g, '%7B').replace(/}/g, '%7D'); - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, expectedBody) - .reply(200, mockResponse); + const encodedOperationId = operationId + .replace(/{/g, "%7B") + .replace(/}/g, "%7D"); + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, + body: expectedBody, + status: 200, + response: mockResponse, + }); - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + const result = await base44.integrations.custom.call( + slug, + operationId, + sdkParams, + ); expect(result.success).toBe(true); - expect(scope.isDone()).toBe(true); }); - test('custom property should not interfere with other integration packages', async () => { + test("custom property should not interfere with other integration packages", async () => { // Test that Core still works const coreParams = { - to: 'test@example.com', - subject: 'Test', - body: 'Test body', + to: "test@example.com", + subject: "Test", + body: "Test body", }; - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`, coreParams) - .reply(200, { success: true }); + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, + body: coreParams, + status: 200, + response: { success: true }, + }); const coreResult = await base44.integrations.Core.SendEmail(coreParams); expect(coreResult.success).toBe(true); // Test that custom packages still work - const customPackageParams = { param: 'value' }; + const customPackageParams = { param: "value" }; - scope - .post( + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/installable/SomePackage/integration-endpoints/SomeEndpoint`, - customPackageParams - ) - .reply(200, { success: true }); + body: customPackageParams, + status: 200, + response: { success: true }, + }); - const packageResult = await base44.integrations.SomePackage.SomeEndpoint(customPackageParams); + const packageResult = + await base44.integrations.SomePackage.SomeEndpoint(customPackageParams); expect(packageResult.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); }); diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts index 7ac3d905..6a53840a 100644 --- a/tests/unit/entities-subscribe.test.ts +++ b/tests/unit/entities-subscribe.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, vi } from "vitest"; +import axios from "axios"; import { createEntitiesModule } from "../../src/modules/entities.ts"; describe("Entities Module - subscribe()", () => { @@ -23,22 +24,18 @@ describe("Entities Module - subscribe()", () => { }; } - // Helper to create a mock axios instance - function createMockAxios() { - return { - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - delete: vi.fn(), - }; + // Use a real HTTP client. Strict MSW teardown rejects any unexpected request, + // including a background refetch whose error is swallowed by the SDK. + function createSubscriptionHttpClient() { + return axios.create({ baseURL: "https://subscription.example.test" }); } test("subscribe() should return an unsubscribe function", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -49,16 +46,16 @@ describe("Entities Module - subscribe()", () => { expect(typeof unsubscribe).toBe("function"); expect(mockSocket.subscribeToRoom).toHaveBeenCalledWith( `entities:${appId}:Todo`, - expect.any(Object) + expect.any(Object), ); }); test("subscribe() should call callback when update_model event is received", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -90,10 +87,10 @@ describe("Entities Module - subscribe()", () => { test("subscribe() should handle update and delete events", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -113,7 +110,7 @@ describe("Entities Module - subscribe()", () => { }); expect(callback).toHaveBeenLastCalledWith( - expect.objectContaining({ type: "update" }) + expect.objectContaining({ type: "update" }), ); // Test delete event @@ -128,17 +125,17 @@ describe("Entities Module - subscribe()", () => { }); expect(callback).toHaveBeenLastCalledWith( - expect.objectContaining({ type: "delete" }) + expect.objectContaining({ type: "delete" }), ); expect(callback).toHaveBeenCalledTimes(2); }); test("subscribe() unsubscribe function should stop receiving events", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -179,10 +176,10 @@ describe("Entities Module - subscribe()", () => { test("subscribe() should not call callback for invalid JSON messages", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -201,7 +198,7 @@ describe("Entities Module - subscribe()", () => { expect(callback).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( "[Base44 SDK] Failed to parse realtime message:", - expect.any(Error) + expect.any(Error), ); warnSpy.mockRestore(); @@ -210,9 +207,9 @@ describe("Entities Module - subscribe()", () => { describe("oversize broadcast handling", () => { test("logs a console.error and passes the stub through when data._oversize is true", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -233,10 +230,12 @@ describe("Entities Module - subscribe()", () => { }); // No HTTP call — the SDK never auto-refetches. - expect(mockAxios.get).not.toHaveBeenCalled(); + // The shared MSW setup asserts no unexpected network traffic. // Developer is notified via console.error. expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("[Base44 SDK] Realtime broadcast for Todo#123 was oversize") + expect.stringContaining( + "[Base44 SDK] Realtime broadcast for Todo#123 was oversize", + ), ); // Callback still fires with the slimmed payload — caller decides what to do. expect(callback).toHaveBeenCalledWith( @@ -244,7 +243,7 @@ describe("Entities Module - subscribe()", () => { type: "update", id: "123", data: { id: "123", _oversize: true }, - }) + }), ); errorSpy.mockRestore(); @@ -252,9 +251,9 @@ describe("Entities Module - subscribe()", () => { test("does NOT log on delete events even if _oversize is set", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -276,7 +275,7 @@ describe("Entities Module - subscribe()", () => { expect(errorSpy).not.toHaveBeenCalled(); expect(callback).toHaveBeenCalledWith( - expect.objectContaining({ type: "delete", id: "123" }) + expect.objectContaining({ type: "delete", id: "123" }), ); errorSpy.mockRestore(); @@ -284,9 +283,9 @@ describe("Entities Module - subscribe()", () => { test("does NOT log when data has no _oversize flag", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -310,7 +309,7 @@ describe("Entities Module - subscribe()", () => { expect(callback).toHaveBeenCalledWith( expect.objectContaining({ data: { id: "123", title: "Normal Todo" }, - }) + }), ); errorSpy.mockRestore(); @@ -319,10 +318,10 @@ describe("Entities Module - subscribe()", () => { test("subscribe() should catch and log errors thrown by callback", () => { const mockSocket = createMockSocket(); - const mockAxios = createMockAxios(); + const httpClient = createSubscriptionHttpClient(); const entities = createEntitiesModule({ - axios: mockAxios as any, + axios: httpClient, appId, getSocket: () => mockSocket as any, }); @@ -355,7 +354,7 @@ describe("Entities Module - subscribe()", () => { // The error should have been logged expect(errorSpy).toHaveBeenCalledWith( "[Base44 SDK] Subscription callback error:", - expect.any(Error) + expect.any(Error), ); errorSpy.mockRestore(); diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts index cc5f580d..2deade29 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -1,7 +1,10 @@ +import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; import { createClient } from "../../src/index.ts"; -import type { DeleteResult, UpdateManyResult } from "../../src/modules/entities.types.ts"; +import type { + DeleteResult, + UpdateManyResult, +} from "../../src/modules/entities.types.ts"; /** * Todo entity type for testing. @@ -22,7 +25,6 @@ declare module "../../src/modules/entities.types.ts" { describe("Entities Module", () => { let base44: ReturnType; - let scope: nock.Scope; const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; @@ -32,23 +34,10 @@ describe("Entities Module", () => { serverUrl, appId, }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on("no match", (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log("Headers:", req.getHeaders()); - }); }); afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners("no match"); - nock.enableNetConnect(); + base44.cleanup(); }); test("list() should fetch entities with correct parameters", async () => { @@ -58,10 +47,13 @@ describe("Entities Module", () => { ]; // Mock the API response - scope - .get(`/api/apps/${appId}/entities/Todo`) - .query(true) // Accept any query parameters - .reply(200, mockTodos); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + query: true, + status: 200, + response: mockTodos, + }); // Call the API const result = await base44.entities.Todo.list("title", 10, 0, [ @@ -72,9 +64,6 @@ describe("Entities Module", () => { // Verify the response expect(result).toHaveLength(2); expect(result[0].title).toBe("Task 1"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("filter() should send correct query parameters", async () => { @@ -82,14 +71,17 @@ describe("Entities Module", () => { const mockTodos: Todo[] = [{ id: "2", title: "Task 2", completed: true }]; // Mock the API response - scope - .get(`/api/apps/${appId}/entities/Todo`) - .query((query) => { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + query: (query) => { // Verify the query contains our filter const parsedQ = JSON.parse(query.q as string); return parsedQ.completed === true; - }) - .reply(200, mockTodos); + }, + status: 200, + response: mockTodos, + }); // Call the API const result = await base44.entities.Todo.filter(filterQuery); @@ -97,17 +89,15 @@ describe("Entities Module", () => { // Verify the response expect(result).toHaveLength(1); expect(result[0].completed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("filter() should support typed advanced query syntax", async () => { const mockTodos: Todo[] = [{ id: "2", title: "Task 2", completed: true }]; - scope - .get(`/api/apps/${appId}/entities/Todo`) - .query((query) => { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + query: (query) => { const parsedQ = JSON.parse(query.q as string); return ( @@ -117,8 +107,10 @@ describe("Entities Module", () => { parsedQ.$or[0].title === "Task 2" && parsedQ.$or[1].completed === true ); - }) - .reply(200, mockTodos); + }, + status: 200, + response: mockTodos, + }); const result = await base44.entities.Todo.filter({ title: { $in: ["Task 1", "Task 2"] }, @@ -127,7 +119,6 @@ describe("Entities Module", () => { }); expect(result).toHaveLength(1); - expect(scope.isDone()).toBe(true); }); test("get() should fetch a single entity", async () => { @@ -139,7 +130,12 @@ describe("Entities Module", () => { }; // Mock the API response - scope.get(`/api/apps/${appId}/entities/Todo/${todoId}`).reply(200, mockTodo); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/entities/Todo/${todoId}`, + status: 200, + response: mockTodo, + }); // Call the API const todo = await base44.entities.Todo.get(todoId); @@ -147,9 +143,6 @@ describe("Entities Module", () => { // Verify the response expect(todo.id).toBe(todoId); expect(todo.title).toBe("Get milk"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("create() should send correct data", async () => { @@ -164,9 +157,13 @@ describe("Entities Module", () => { }; // Mock the API response - scope - .post(`/api/apps/${appId}/entities/Todo`, newTodo as nock.RequestBodyMatcher) - .reply(201, createdTodo); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/entities/Todo`, + body: newTodo, + status: 201, + response: createdTodo, + }); // Call the API const todo = await base44.entities.Todo.create(newTodo); @@ -174,9 +171,6 @@ describe("Entities Module", () => { // Verify the response expect(todo.id).toBe("123"); expect(todo.title).toBe("New task"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("update() should send correct data", async () => { @@ -192,12 +186,13 @@ describe("Entities Module", () => { }; // Mock the API response - scope - .put( - `/api/apps/${appId}/entities/Todo/${todoId}`, - updates as nock.RequestBodyMatcher - ) - .reply(200, updatedTodo); + mockHttp({ + method: "put", + url: serverUrl + `/api/apps/${appId}/entities/Todo/${todoId}`, + body: updates, + status: 200, + response: updatedTodo, + }); // Call the API const todo = await base44.entities.Todo.update(todoId, updates); @@ -206,9 +201,6 @@ describe("Entities Module", () => { expect(todo.id).toBe(todoId); expect(todo.title).toBe("Updated task"); expect(todo.completed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("delete() should call correct endpoint and return DeleteResult", async () => { @@ -216,18 +208,18 @@ describe("Entities Module", () => { const deleteResult: DeleteResult = { success: true }; // Mock the API response - scope - .delete(`/api/apps/${appId}/entities/Todo/${todoId}`) - .reply(200, deleteResult); + mockHttp({ + method: "delete", + url: serverUrl + `/api/apps/${appId}/entities/Todo/${todoId}`, + status: 200, + response: deleteResult, + }); // Call the API const result = await base44.entities.Todo.delete(todoId); // Verify the response matches DeleteResult type expect(result.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("updateMany() should send query and data to correct endpoint", async () => { @@ -238,26 +230,27 @@ describe("Entities Module", () => { }; // Mock the API response - scope - .patch(`/api/apps/${appId}/entities/Todo/update-many`, { + mockHttp({ + method: "patch", + url: serverUrl + `/api/apps/${appId}/entities/Todo/update-many`, + body: { query: { completed: false }, data: { $set: { completed: true } }, - }) - .reply(200, mockResult); + }, + status: 200, + response: mockResult, + }); // Call the API const result = await base44.entities.Todo.updateMany( { completed: false }, - { $set: { completed: true } } + { $set: { completed: true } }, ); // Verify the response expect(result.success).toBe(true); expect(result.updated).toBe(3); expect(result.has_more).toBe(false); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("updateMany() should handle has_more response", async () => { @@ -268,26 +261,27 @@ describe("Entities Module", () => { }; // Mock the API response - scope - .patch(`/api/apps/${appId}/entities/Todo/update-many`, { + mockHttp({ + method: "patch", + url: serverUrl + `/api/apps/${appId}/entities/Todo/update-many`, + body: { query: {}, data: { $inc: { view_count: 1 } }, - }) - .reply(200, mockResult); + }, + status: 200, + response: mockResult, + }); // Call the API const result = await base44.entities.Todo.updateMany( {}, - { $inc: { view_count: 1 } } + { $inc: { view_count: 1 } }, ); // Verify the response expect(result.success).toBe(true); expect(result.updated).toBe(500); expect(result.has_more).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("bulkUpdate() should send array of updates to correct endpoint", async () => { @@ -301,12 +295,13 @@ describe("Entities Module", () => { ]; // Mock the API response - scope - .put( - `/api/apps/${appId}/entities/Todo/bulk`, - updatePayload as nock.RequestBodyMatcher - ) - .reply(200, mockResponse); + mockHttp({ + method: "put", + url: serverUrl + `/api/apps/${appId}/entities/Todo/bulk`, + body: updatePayload, + status: 200, + response: mockResponse, + }); // Call the API const result = await base44.entities.Todo.bulkUpdate(updatePayload); @@ -318,9 +313,5 @@ describe("Entities Module", () => { expect(result[0].completed).toBe(true); expect(result[1].id).toBe("2"); expect(result[1].title).toBe("Updated Task 2"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); - }); diff --git a/tests/unit/fetch-with-auth.test.ts b/tests/unit/fetch-with-auth.test.ts index 73167ff2..d1a281b6 100644 --- a/tests/unit/fetch-with-auth.test.ts +++ b/tests/unit/fetch-with-auth.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { createClient, createClientFromRequest } from "../../src/index.ts"; +import { http, HttpResponse } from "msw"; +import { server } from "../mocks/server"; +import { createClient, createClientFromRequest as fromRequest } from "../../src/index.ts"; const appId = "test-app-id"; const origin = "https://my-app.base44.app"; @@ -35,29 +37,44 @@ function stubBrowser(storage = makeLocalStorage()) { vi.stubGlobal("localStorage", storage); } -const createTestClient = (token?: string) => - createClient({ - serverUrl: "", - appId, - token, - analytics: { enabled: false }, - }); - -let fetchMock: ReturnType; - +// Node has no browser-relative fetch base. This injected platform adapter resolves +// the URL, then uses real fetch intercepted by MSW; it never fabricates a response. +const transportCalls: Array<[string, RequestInit]> = []; +let requests: Request[] = []; +const clients: Array> = []; +const createClientFromRequest = (request: Request) => { + const client = fromRequest(request); + clients.push(client); + return client; +}; +const transport: typeof fetch = async (input, init = {}) => { + transportCalls.push([String(input), init]); + return fetch(new URL(String(input), origin), init); +}; +const createTestClient = (token?: string) => { + const client = createClient({serverUrl: "", appId, token, analytics: {enabled: false}}); + clients.push(client); + const original = client.fetchWithAuth.bind(client); + client.fetchWithAuth = (path, options = {}) => original(path, {fetch: transport, ...options}); + return client; +}; beforeEach(() => { - fetchMock = vi.fn().mockResolvedValue(new Response("{}")); - vi.stubGlobal("fetch", fetchMock); + transportCalls.length = 0; + requests = []; + server.use(http.all(`${origin}/api/*`, ({request}) => { + requests.push(request.clone()); + return HttpResponse.json({}); + })); }); - afterEach(() => { + for (const client of clients.splice(0)) client.cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); }); - const lastCall = () => { - const [url, init] = fetchMock.mock.calls[0]; - return { url, init, headers: new Headers(init.headers) }; + expect(requests).toHaveLength(1); + const [url, init] = transportCalls[0]!; + return {url, init, headers: requests[0]!.headers}; }; describe("fetchWithAuth", () => { @@ -110,7 +127,7 @@ describe("fetchWithAuth", () => { await base44.fetchWithAuth("/api/public"); expect(lastCall().headers.get("Authorization")).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(requests).toHaveLength(1); }); test("forwards init options and keeps a caller-set Authorization header", async () => { @@ -129,6 +146,7 @@ describe("fetchWithAuth", () => { const { init, headers } = lastCall(); expect(init.method).toBe("POST"); expect(init.body).toBe(JSON.stringify({ productId: "abc" })); + expect(await requests[0]!.json()).toEqual({productId: "abc"}); expect(headers.get("Content-Type")).toBe("application/json"); expect(headers.get("Authorization")).toBe("Bearer caller-token"); }); @@ -160,7 +178,8 @@ describe("fetchWithAuth", () => { await expect(base44.fetchWithAuth(path)).rejects.toThrow( /only sends requests to your app's own origin/ ); - expect(fetchMock).not.toHaveBeenCalled(); + expect(transportCalls).toHaveLength(0); + expect(requests).toHaveLength(0); }); test("rejects an empty path", async () => { @@ -168,7 +187,8 @@ describe("fetchWithAuth", () => { const base44 = createTestClient("user-token"); await expect(base44.fetchWithAuth("")).rejects.toThrow(/requires a path/); - expect(fetchMock).not.toHaveBeenCalled(); + expect(transportCalls).toHaveLength(0); + expect(requests).toHaveLength(0); }); test("works with no document, as in a server route", async () => { @@ -188,7 +208,8 @@ describe("fetchWithAuth", () => { await expect( base44.fetchWithAuth("https://evil.example/steal") ).rejects.toThrow(/only sends requests to your app's own origin/); - expect(fetchMock).not.toHaveBeenCalled(); + expect(transportCalls).toHaveLength(0); + expect(requests).toHaveLength(0); }); }); @@ -220,7 +241,7 @@ describe("fetchWithAuth from a server route", () => { test("sends every header createClientFromRequest reads, so the callee rebuilds the same client", async () => { const base44 = createClientFromRequest(inboundRequest()); - await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + await base44.fetchWithAuth("/api/items", { fetch: transport }); const { url, headers } = lastCall(); expect(url).toBe("/api/items"); @@ -235,7 +256,7 @@ describe("fetchWithAuth from a server route", () => { test("carries the service credential, so asServiceRole works in the callee", async () => { const base44 = createClientFromRequest(inboundRequest()); - await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + await base44.fetchWithAuth("/api/items", { fetch: transport }); expect(lastCall().headers.get("Base44-Service-Authorization")).toBe( "Bearer service-credential" @@ -245,7 +266,7 @@ describe("fetchWithAuth from a server route", () => { test("does not forward host, which would repoint the sub-request's origin", async () => { const base44 = createClientFromRequest(inboundRequest()); - await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + await base44.fetchWithAuth("/api/items", { fetch: transport }); expect(lastCall().headers.has("host")).toBe(false); }); @@ -253,7 +274,7 @@ describe("fetchWithAuth from a server route", () => { test("forwards nothing from the inbound request beyond that set", async () => { const base44 = createClientFromRequest(inboundRequest()); - await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + await base44.fetchWithAuth("/api/items", { fetch: transport }); expect(lastCall().headers.has("cookie")).toBe(false); }); @@ -263,7 +284,7 @@ describe("fetchWithAuth from a server route", () => { inboundRequest({ Authorization: undefined }) ); - await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + await base44.fetchWithAuth("/api/items", { fetch: transport }); const { headers } = lastCall(); expect(headers.has("Authorization")).toBe(false); @@ -281,7 +302,7 @@ describe("fetchWithAuth from a server route", () => { }) ); - await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + await base44.fetchWithAuth("/api/items", { fetch: transport }); const { headers } = lastCall(); expect(headers.has("Base44-State")).toBe(false); @@ -293,7 +314,7 @@ describe("fetchWithAuth from a server route", () => { const base44 = createClientFromRequest(inboundRequest()); await base44.fetchWithAuth("/api/items", { - fetch: fetchMock, + fetch: transport, headers: { Authorization: "" }, }); @@ -301,12 +322,12 @@ describe("fetchWithAuth from a server route", () => { }); test("uses the given transport and does not pass it on as request init", async () => { - vi.stubGlobal("fetch", vi.fn()); const base44 = createClientFromRequest(inboundRequest()); - await base44.fetchWithAuth("/api/items", { fetch: fetchMock }); + await base44.fetchWithAuth("/api/items", { fetch: transport }); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(transportCalls).toHaveLength(1); + expect(requests).toHaveLength(1); expect(lastCall().init).not.toHaveProperty("fetch"); }); @@ -314,9 +335,10 @@ describe("fetchWithAuth from a server route", () => { const base44 = createClientFromRequest(inboundRequest()); await expect( - base44.fetchWithAuth("https://evil.example/steal", { fetch: fetchMock }) + base44.fetchWithAuth("https://evil.example/steal", { fetch: transport }) ).rejects.toThrow(/only sends requests to your app's own origin/); - expect(fetchMock).not.toHaveBeenCalled(); + expect(transportCalls).toHaveLength(0); + expect(requests).toHaveLength(0); }); }); diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index 9a55379b..7f332096 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -1,5 +1,7 @@ +import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; -import nock from "nock"; +import { http, HttpResponse } from "msw"; +import { server } from "../mocks/server"; import { createClient } from "../../src/index.ts"; // Module augmentation: register function names in FunctionNameRegistry @@ -13,8 +15,6 @@ declare module "../../src/modules/functions.types.ts" { describe("Functions Module", () => { let base44: ReturnType; - let scope; - let fetchMock: ReturnType; const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; @@ -24,28 +24,10 @@ describe("Functions Module", () => { serverUrl, appId, }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on("no match", (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log("Headers:", req.getHeaders()); - }); - - fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners("no match"); - nock.enableNetConnect(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); + base44.cleanup(); }); test("should call a function with JSON data", async () => { @@ -57,13 +39,17 @@ describe("Functions Module", () => { }; // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: functionData, + headers: [["Content-Type", "application/json"]], + status: 200, + response: { success: true, messageId: "msg-456", - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -71,31 +57,29 @@ describe("Functions Module", () => { // Verify the response expect(result.data.success).toBe(true); expect(result.data.messageId).toBe("msg-456"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle function with empty object parameters", async () => { const functionName = "getStatus"; // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, {}) - .matchHeader("Content-Type", "application/json") - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: {}, + headers: [["Content-Type", "application/json"]], + status: 200, + response: { status: "healthy", timestamp: "2024-01-01T00:00:00Z", - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, {}); // Verify the response expect(result.data.status).toBe("healthy"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle function with complex nested objects", async () => { @@ -118,22 +102,23 @@ describe("Functions Module", () => { }; // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: functionData, + headers: [["Content-Type", "application/json"]], + status: 200, + response: { processed: true, userId: "123", - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, functionData); // Verify the response expect(result.data.processed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle file uploads with FormData", async () => { @@ -146,11 +131,18 @@ describe("Functions Module", () => { }; // Mock the API response - // TODO: Add validation to the request body - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(() => { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + headers: [["Content-Type", /^multipart\/form-data/]], + inspect: async (request) => { + const form = await request.formData(); + const file = form.get("file") as File; + expect(file.name).toBe("test.txt"); + expect(file.type).toBe("text/plain"); + expect(await file.text()).toBe("test content"); + }, + respond: () => { return [ 200, { @@ -159,7 +151,8 @@ describe("Functions Module", () => { size: 12, }, ]; - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -167,9 +160,6 @@ describe("Functions Module", () => { // Verify the response expect(result.data.fileId).toBe("file-789"); expect(result.data.filename).toBe("test.txt"); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle mixed data with files and regular data", async () => { @@ -188,15 +178,28 @@ describe("Functions Module", () => { }; // Mock the API response - // TODO: Add validation to the request body - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + headers: [["Content-Type", /^multipart\/form-data/]], + inspect: async (request) => { + const form = await request.formData(); + const file = form.get("file") as File; + expect(file.name).toBe("document.pdf"); + expect(file.type).toBe("application/pdf"); + expect(await file.text()).toBe("document content"); + expect(JSON.parse(form.get("metadata") as string)).toEqual( + functionData.metadata, + ); + expect(form.get("priority")).toBe("high"); + }, + status: 200, + response: { documentId: "doc-123", processed: true, extractedText: "document content", - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -204,9 +207,6 @@ describe("Functions Module", () => { // Verify the response expect(result.data.documentId).toBe("doc-123"); expect(result.data.processed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle FormData input directly", async () => { @@ -217,14 +217,23 @@ describe("Functions Module", () => { formData.append("message", "Hello there"); // Mock the API response - // TODO: Add validation to the request body - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + headers: [["Content-Type", /^multipart\/form-data/]], + inspect: async (request) => { + expect(Object.fromEntries(await request.formData())).toEqual({ + name: "John Doe", + email: "john@example.com", + message: "Hello there", + }); + }, + status: 200, + response: { formId: "form-456", submitted: true, - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, formData); @@ -232,9 +241,39 @@ describe("Functions Module", () => { // Verify the response expect(result.data.formId).toBe("form-456"); expect(result.data.submitted).toBe(true); + }); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + test("direct FormData preserves repeated keys, binary files and empty values", async () => { + const form = new FormData(); + form.append("tag", "one"); + form.append("tag", "two"); + form.append("empty", ""); + form.append( + "file", + new File([new Uint8Array([0, 255, 10])], "bytes.bin", { + type: "application/octet-stream", + }), + ); + mockHttp({ + method: "post", + url: `${serverUrl}/api/apps/${appId}/functions/upload`, + response: { ok: true }, + inspect: async (request) => { + const actual = await request.formData(); + expect(actual.getAll("tag")).toEqual(["one", "two"]); + expect(actual.get("empty")).toBe(""); + const file = actual.get("file") as File; + expect(file.name).toBe("bytes.bin"); + expect(file.type).toBe("application/octet-stream"); + expect([...new Uint8Array(await file.arrayBuffer())]).toEqual([ + 0, 255, 10, + ]); + }, + }); + expect((await base44.functions.invoke("upload", form)).data).toEqual({ + ok: true, + }); + expect(form.getAll("tag")).toEqual(["one", "two"]); }); test("should throw error for string input instead of object", async () => { @@ -243,9 +282,9 @@ describe("Functions Module", () => { // Call the function with string input (should throw) await expect( // @ts-expect-error - base44.functions.invoke(functionName, "invalid string input") + base44.functions.invoke(functionName, "invalid string input"), ).rejects.toThrow( - `Function ${functionName} must receive an object with named parameters, received: invalid string input` + `Function ${functionName} must receive an object with named parameters, received: invalid string input`, ); }); @@ -256,21 +295,22 @@ describe("Functions Module", () => { }; // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: functionData, + headers: [["Content-Type", "application/json"]], + status: 200, + response: { processed: true, - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, functionData); // Verify the response expect(result.data.processed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle API errors gracefully", async () => { @@ -280,21 +320,22 @@ describe("Functions Module", () => { }; // Mock the API error response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(500, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: functionData, + headers: [["Content-Type", "application/json"]], + status: 500, + response: { error: "Internal server error", code: "INTERNAL_ERROR", - }); + }, + }); // Call the function and expect it to throw await expect( - base44.functions.invoke(functionName, functionData) + base44.functions.invoke(functionName, functionData), ).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle 404 errors for non-existent functions", async () => { @@ -304,21 +345,22 @@ describe("Functions Module", () => { }; // Mock the API 404 response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(404, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: functionData, + headers: [["Content-Type", "application/json"]], + status: 404, + response: { error: "Function not found", code: "FUNCTION_NOT_FOUND", - }); + }, + }); // Call the function and expect it to throw await expect( - base44.functions.invoke(functionName, functionData) + base44.functions.invoke(functionName, functionData), ).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle null and undefined values in data", async () => { @@ -331,22 +373,23 @@ describe("Functions Module", () => { }; // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: functionData, + headers: [["Content-Type", "application/json"]], + status: 200, + response: { received: true, values: functionData, - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, functionData); // Verify the response expect(result.data.received).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should handle array values in data", async () => { @@ -358,13 +401,17 @@ describe("Functions Module", () => { }; // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: functionData, + headers: [["Content-Type", "application/json"]], + status: 200, + response: { processed: true, count: 3, - }); + }, + }); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -372,9 +419,6 @@ describe("Functions Module", () => { // Verify the response expect(result.data.processed).toBe(true); expect(result.data.count).toBe(3); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should create FormData correctly when files are present", async () => { @@ -387,19 +431,19 @@ describe("Functions Module", () => { }; // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { success: true }); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + headers: [["Content-Type", /^multipart\/form-data/]], + status: 200, + response: { success: true }, + }); // Call the function const result = await base44.functions.invoke(functionName, functionData); // Verify the response expect(result.data.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should create FormData correctly when FormData is passed directly", async () => { @@ -409,19 +453,19 @@ describe("Functions Module", () => { formData.append("email", "john@example.com"); // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { success: true }); + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + headers: [["Content-Type", /^multipart\/form-data/]], + status: 200, + response: { success: true }, + }); // Call the function const result = await base44.functions.invoke(functionName, formData); // Verify the response expect(result.data.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should send user token as Authorization header when invoking functions", async () => { @@ -439,41 +483,46 @@ describe("Functions Module", () => { }); // Mock the API response, verifying the Authorization header - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .matchHeader("Authorization", `Bearer ${userToken}`) - .reply(200, { + mockHttp({ + method: "post", + url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, + body: functionData, + headers: [ + ["Content-Type", "application/json"], + ["Authorization", `Bearer ${userToken}`], + ], + status: 200, + response: { success: true, authenticated: true, - }); + }, + }); // Call the function - const result = await authenticatedBase44.functions.invoke(functionName, functionData); + const result = await authenticatedBase44.functions.invoke( + functionName, + functionData, + ); // Verify the response expect(result.data.success).toBe(true); expect(result.data.authenticated).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); }); test("should fetch function endpoint directly", async () => { - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + let capturedUrl: string | null = null; + server.use( + http.get(`${serverUrl}/api/functions/my_function`, ({ request }) => { + capturedUrl = request.url; + return new HttpResponse("ok", { status: 200 }); + }), + ); - await base44.functions.fetch("/my_function", { - method: "GET", - }); + await base44.functions.fetch("/my_function", { method: "GET" }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `${serverUrl}/api/functions/my_function`, - expect.any(Object) - ); + expect(capturedUrl).toBe(`${serverUrl}/api/functions/my_function`); }); - test("should include Authorization header when using functions.fetch", async () => { const userToken = "user-streaming-token"; const authenticatedBase44 = createClient({ @@ -481,51 +530,60 @@ describe("Functions Module", () => { appId, token: userToken, }); - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + + let capturedAuth: string | null = null; + server.use( + http.post(`${serverUrl}/api/functions/streaming_demo`, ({ request }) => { + capturedAuth = request.headers.get("Authorization"); + return new HttpResponse("ok", { status: 200 }); + }), + ); await authenticatedBase44.functions.fetch("streaming_demo", { method: "POST", body: JSON.stringify({ mode: "text" }), }); - const requestInit = fetchMock.mock.calls[0][1]; - const headers = new Headers(requestInit.headers); - expect(headers.get("Authorization")).toBe(`Bearer ${userToken}`); + expect(capturedAuth).toBe(`Bearer ${userToken}`); + + authenticatedBase44.cleanup(); }); test("should normalize path with and without leading slash", async () => { - // Test with leading slash - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); - await base44.functions.fetch("/my_function"); - expect(fetchMock).toHaveBeenCalledWith( - `${serverUrl}/api/functions/my_function`, - expect.any(Object) + const calledUrls: string[] = []; + server.use( + http.get(`${serverUrl}/api/functions/my_function`, ({ request }) => { + calledUrls.push(request.url); + return new HttpResponse("ok", { status: 200 }); + }), ); - // Test without leading slash - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + await base44.functions.fetch("/my_function"); await base44.functions.fetch("my_function"); - expect(fetchMock).toHaveBeenCalledWith( - `${serverUrl}/api/functions/my_function`, - expect.any(Object) - ); + + expect(calledUrls).toHaveLength(2); + expect(calledUrls[0]).toBe(`${serverUrl}/api/functions/my_function`); + expect(calledUrls[1]).toBe(`${serverUrl}/api/functions/my_function`); }); test("should include service role Authorization header when using asServiceRole.functions.fetch", async () => { const serviceToken = "service-role-token"; - const serviceRoleBase44 = createClient({ - serverUrl, - appId, - serviceToken, - }); - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + const serviceRoleBase44 = createClient({ serverUrl, appId, serviceToken }); + + let capturedAuth: string | null = null; + server.use( + http.get(`${serverUrl}/api/functions/service_function`, ({ request }) => { + capturedAuth = request.headers.get("Authorization"); + return new HttpResponse("ok", { status: 200 }); + }), + ); await serviceRoleBase44.asServiceRole.functions.fetch("/service_function", { method: "GET", }); - const requestInit = fetchMock.mock.calls[0][1]; - const headers = new Headers(requestInit.headers); - expect(headers.get("Authorization")).toBe(`Bearer ${serviceToken}`); + expect(capturedAuth).toBe(`Bearer ${serviceToken}`); + + serviceRoleBase44.cleanup(); }); }); diff --git a/tests/unit/integrations.test.js b/tests/unit/integrations.test.js index 159c47ee..dfa20e92 100644 --- a/tests/unit/integrations.test.js +++ b/tests/unit/integrations.test.js @@ -1,122 +1,131 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; +import { mockHttp } from "../mocks/http"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { createClient } from "../../src/index.ts"; -describe('Integrations Module', () => { +describe("Integrations Module", () => { let base44; - let scope; - const appId = 'test-app-id'; - const serverUrl = 'https://base44.app'; - + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + beforeEach(() => { // Create a new client for each test base44 = createClient({ serverUrl, appId, }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); }); - + afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); + base44.cleanup(); }); - - test('Core integration should send requests to the correct endpoint', async () => { + + test("Core integration should send requests to the correct endpoint", async () => { const emailParams = { - to: 'test@example.com', - subject: 'Test Email', - body: 'This is a test email' + to: "test@example.com", + subject: "Test Email", + body: "This is a test email", }; - + // Mock the API response - scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`, emailParams) - .reply(200, { success: true, messageId: '123456' }); - + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, + body: emailParams, + status: 200, + response: { success: true, messageId: "123456" }, + }); + // Call the API const result = await base44.integrations.Core.SendEmail(emailParams); - + // Verify the response expect(result.success).toBe(true); - expect(result.messageId).toBe('123456'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.messageId).toBe("123456"); }); - - test('Custom package integration should send requests to the correct endpoint', async () => { + + test("Custom package integration should send requests to the correct endpoint", async () => { const customParams = { - param1: 'value1', - param2: 'value2' + param1: "value1", + param2: "value2", }; - + // Mock the API response - scope.post(`/api/apps/${appId}/integration-endpoints/installable/CustomPackage/integration-endpoints/CustomEndpoint`, customParams) - .reply(200, { success: true, result: 'custom result' }); - + mockHttp({ + method: "post", + url: + serverUrl + + `/api/apps/${appId}/integration-endpoints/installable/CustomPackage/integration-endpoints/CustomEndpoint`, + body: customParams, + status: 200, + response: { success: true, result: "custom result" }, + }); + // Call the API - const result = await base44.integrations.CustomPackage.CustomEndpoint(customParams); - + const result = + await base44.integrations.CustomPackage.CustomEndpoint(customParams); + // Verify the response expect(result.success).toBe(true); - expect(result.result).toBe('custom result'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.result).toBe("custom result"); }); - - test('Integration should handle file uploads correctly', async () => { + + test("Integration should handle file uploads correctly", async () => { // Mock a file - const mockFile = new Blob(['file content'], { type: 'text/plain' }); - mockFile.name = 'test.txt'; - + const mockFile = new Blob(["file content"], { type: "text/plain" }); + mockFile.name = "test.txt"; + const uploadParams = { file: mockFile, - metadata: { type: 'document' } + metadata: { type: "document" }, }; - - // Mock the API response - note that we can't easily check FormData contents with nock - // so we just make sure the endpoint is called - scope.post(`/api/apps/${appId}/integration-endpoints/Core/UploadFile`) - .reply(200, { success: true, fileId: 'file123' }); - + + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/UploadFile`, + status: 200, + response: { success: true, fileId: "file123" }, + }); + // Call the API const result = await base44.integrations.Core.UploadFile(uploadParams); - + // Verify the response expect(result.success).toBe(true); - expect(result.fileId).toBe('file123'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(result.fileId).toBe("file123"); }); - - test('Integration should throw error with string parameters', async () => { + + test("Integration should throw error with string parameters", async () => { // Expect error when trying to call with a string instead of object await expect(async () => { - await base44.integrations.Core.SendEmail('invalid string parameter'); - }).rejects.toThrow('Integration SendEmail must receive an object with named parameters'); + await base44.integrations.Core.SendEmail("invalid string parameter"); + }).rejects.toThrow( + "Integration SendEmail must receive an object with named parameters", + ); }); - - test('Integration should handle API errors correctly', async () => { - const params = { invalid: 'params' }; - + + test("Integration should handle API errors correctly", async () => { + const params = { invalid: "params" }; + // Mock an API error response - scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`, params) - .reply(400, { detail: 'Invalid parameters', code: 'INVALID_PARAMS' }); - + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, + body: params, + status: 400, + response: { detail: "Invalid parameters", code: "INVALID_PARAMS" }, + }); + // Call the API and expect an error - await expect(base44.integrations.Core.SendEmail(params)) - .rejects.toMatchObject({ - status: 400, - name: 'Base44Error', - message: 'Invalid parameters', - code: 'INVALID_PARAMS' - }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + await expect( + base44.integrations.Core.SendEmail(params), + ).rejects.toMatchObject({ + status: 400, + name: "Base44Error", + message: "Invalid parameters", + code: "INVALID_PARAMS", + }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/integrations.test.ts b/tests/unit/integrations.test.ts index 88fca9b2..13a6af2b 100644 --- a/tests/unit/integrations.test.ts +++ b/tests/unit/integrations.test.ts @@ -1,75 +1,84 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; +import { mockHttp } from "../mocks/http"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { createClient } from "../../src/index.ts"; -describe('Core Integrations - InvokeLLM', () => { +describe("Core Integrations - InvokeLLM", () => { let base44: ReturnType; - let scope: nock.Scope; - const appId = 'test-app-id'; - const serverUrl = 'https://base44.app'; + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; beforeEach(() => { base44 = createClient({ serverUrl, appId, }); - - scope = nock(serverUrl); }); afterEach(() => { - nock.cleanAll(); + base44.cleanup(); }); - test('InvokeLLM should pass model parameter to the API', async () => { + test("InvokeLLM should pass model parameter to the API", async () => { const params = { - prompt: 'Explain quantum computing', - model: 'gpt_5', + prompt: "Explain quantum computing", + model: "gpt_5", }; - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, params) - .reply(200, 'Quantum computing uses qubits...'); + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, + body: params, + status: 200, + response: "Quantum computing uses qubits...", + }); const result = await base44.integrations.Core.InvokeLLM(params); - expect(result).toBe('Quantum computing uses qubits...'); - expect(scope.isDone()).toBe(true); + expect(result).toBe("Quantum computing uses qubits..."); }); - test('InvokeLLM should work without model parameter', async () => { + test("InvokeLLM should work without model parameter", async () => { const params = { - prompt: 'Explain quantum computing', + prompt: "Explain quantum computing", }; - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, params) - .reply(200, 'Quantum computing uses qubits...'); + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, + body: params, + status: 200, + response: "Quantum computing uses qubits...", + }); const result = await base44.integrations.Core.InvokeLLM(params); - expect(result).toBe('Quantum computing uses qubits...'); - expect(scope.isDone()).toBe(true); + expect(result).toBe("Quantum computing uses qubits..."); }); - test('InvokeLLM should pass model alongside other optional parameters', async () => { + test("InvokeLLM should pass model alongside other optional parameters", async () => { const params = { - prompt: 'Analyze this text', - model: 'claude_sonnet_4_6' as const, + prompt: "Analyze this text", + model: "claude_sonnet_4_6" as const, response_json_schema: { - type: 'object', + type: "object", properties: { - sentiment: { type: 'string' }, + sentiment: { type: "string" }, }, }, }; - const mockResponse = { sentiment: 'positive' }; + const mockResponse = { sentiment: "positive" }; - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, params) - .reply(200, mockResponse); + mockHttp({ + method: "post", + url: + serverUrl + `/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, + body: params, + status: 200, + response: mockResponse, + }); const result = await base44.integrations.Core.InvokeLLM(params); expect(result).toEqual(mockResponse); - expect(scope.isDone()).toBe(true); }); }); diff --git a/tests/unit/sso.test.ts b/tests/unit/sso.test.ts index 04fe2c50..14e19725 100644 --- a/tests/unit/sso.test.ts +++ b/tests/unit/sso.test.ts @@ -1,5 +1,5 @@ +import { mockHttp } from "../mocks/http"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import nock from "nock"; import { createClient } from "../../src/index.ts"; describe("SSO module", () => { @@ -9,7 +9,6 @@ describe("SSO module", () => { const userToken = "user-token-456"; const userId = "user_123"; let base44: ReturnType; - let scope: nock.Scope; beforeEach(() => { base44 = createClient({ @@ -18,77 +17,86 @@ describe("SSO module", () => { token: userToken, serviceToken, }); - scope = nock(serverUrl); }); afterEach(() => { - nock.cleanAll(); + base44.cleanup(); }); test("getIdToken issues the app-scoped GET request and returns the raw token", async () => { const rawIdToken = "header.payload.signature"; - scope - .get(`/api/apps/${appId}/auth/sso/idtoken/${userId}`) - .reply(200, JSON.stringify(rawIdToken), { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/auth/sso/idtoken/${userId}`, + status: 200, + response: JSON.stringify(rawIdToken), + responseHeaders: { "Content-Type": "application/json", - }); + }, + }); - const idToken: string = - await base44.asServiceRole.sso.getIdToken(userId); + const idToken: string = await base44.asServiceRole.sso.getIdToken(userId); expect(idToken).toBe(rawIdToken); - expect(scope.isDone()).toBe(true); }); test("getAccessToken issues the existing GET request and returns the raw token", async () => { const rawAccessToken = "access-token-123"; - scope - .get(`/api/apps/${appId}/auth/sso/accesstoken/${userId}`) - .reply(200, JSON.stringify(rawAccessToken), { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/auth/sso/accesstoken/${userId}`, + status: 200, + response: JSON.stringify(rawAccessToken), + responseHeaders: { "Content-Type": "application/json", - }); + }, + }); - const accessToken = - await base44.asServiceRole.sso.getAccessToken(userId); + const accessToken = await base44.asServiceRole.sso.getAccessToken(userId); // Preserve the legacy public response type for compatibility while // locking down the endpoint's existing raw-string runtime behavior. expect(accessToken).toBe(rawAccessToken); - expect(scope.isDone()).toBe(true); }); test("getIdToken uses the service-role client with on-behalf-of authentication", async () => { const rawIdToken = "raw-id-token"; - scope - .get(`/api/apps/${appId}/auth/sso/idtoken/${userId}`) - .matchHeader("Authorization", `Bearer ${serviceToken}`) - .matchHeader("on-behalf-of", `Bearer ${userToken}`) - .reply(200, JSON.stringify(rawIdToken), { + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/auth/sso/idtoken/${userId}`, + headers: [ + ["Authorization", `Bearer ${serviceToken}`], + ["on-behalf-of", `Bearer ${userToken}`], + ], + status: 200, + response: JSON.stringify(rawIdToken), + responseHeaders: { "Content-Type": "application/json", - }); + }, + }); const idToken = await base44.asServiceRole.sso.getIdToken(userId); expect(idToken).toBe(rawIdToken); - expect(scope.isDone()).toBe(true); }); test("getIdToken surfaces a 404 when no ID token is stored", async () => { - scope - .get(`/api/apps/${appId}/auth/sso/idtoken/${userId}`) - .reply(404, { detail: "No ID token stored", code: "NOT_FOUND" }); + mockHttp({ + method: "get", + url: serverUrl + `/api/apps/${appId}/auth/sso/idtoken/${userId}`, + status: 404, + response: { detail: "No ID token stored", code: "NOT_FOUND" }, + }); await expect( - base44.asServiceRole.sso.getIdToken(userId) + base44.asServiceRole.sso.getIdToken(userId), ).rejects.toMatchObject({ name: "Base44Error", status: 404, code: "NOT_FOUND", }); - - expect(scope.isDone()).toBe(true); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index d3a67f43..26f04757 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ environment: "node", globals: true, setupFiles: ["./tests/setup.js"], - include: ["tests/**/*.test.js", "tests/**/*.test.ts"], + include: ["tests/unit/**/*.test.js", "tests/unit/**/*.test.ts"], coverage: { reporter: ["text", "json", "html"], }, diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 00000000..daa1c051 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +// These tests exercise real APIs and can create/delete data. Never run them as +// part of npm test or with MSW enabled. Require an explicit operator opt-in. +if (process.env.BASE44_RUN_E2E !== "true") { + throw new Error( + "Live E2E tests require BASE44_RUN_E2E=true and dedicated test credentials. See tests/README.md.", + ); +} +export default defineConfig({ + test: { + environment: "node", + globals: true, + testTimeout: 30000, + setupFiles: ["./tests/setup.e2e.js"], + include: ["tests/e2e/**/*.test.js", "tests/e2e/**/*.test.ts"], + }, +}); From 1d574ef211e72d4ca4f7e5e636f50e0d078c12ce Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 8 Sep 2026 14:33:48 +0300 Subject: [PATCH 4/9] test: honor dependency cooldown in MSW lockfile --- package-lock.json | 60 +++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69f65845..c9c8621e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -535,9 +535,9 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", - "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", "dev": true, "license": "MIT", "engines": { @@ -545,14 +545,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.2.tgz", - "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.2.0.tgz", + "integrity": "sha512-SKXarWrYhtpqOEctf9XGCGy29QjsvJAM0Aq9ZR9z4Ns94OmpqudOly+aSEfNqUf9SwsQaUgY9+Z8hyzG0xX8fw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.0", + "@inquirer/type": "^4.0.7" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -567,15 +567,15 @@ } }, "node_modules/@inquirer/core": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.0.tgz", + "integrity": "sha512-+nnvFEXIB08CZNVXpvW3B+zHW96QXvGUjNKJ8NJIPqAZi5Kd4WhYt2S3C234ReepG1qw2HOlEUbjYVHBowXObA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -594,9 +594,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", - "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", + "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", "dev": true, "license": "MIT", "engines": { @@ -604,9 +604,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", - "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", "dev": true, "license": "MIT", "engines": { @@ -5589,22 +5589,22 @@ } }, "node_modules/tldts": { - "version": "7.4.12", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", - "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.12" + "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.12", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", - "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", "dev": true, "license": "MIT" }, @@ -5692,9 +5692,9 @@ } }, "node_modules/type-fest": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", - "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { From ac78d8d34cf81ffdffc98b5de702098bdbc444b3 Mon Sep 17 00:00:00 2001 From: base44-os-gremlins Bot Date: Tue, 8 Sep 2026 14:14:19 +0000 Subject: [PATCH 5/9] test: model Base44 with stateful MSW platform --- tests/README.md | 74 ++- tests/mocks/http.ts | 126 ----- tests/mocks/platform/actors.ts | 49 ++ tests/mocks/platform/agents.ts | 48 ++ tests/mocks/platform/analytics.ts | 10 + tests/mocks/platform/app.ts | 37 ++ tests/mocks/platform/auth.ts | 209 ++++++++ tests/mocks/platform/connectors.ts | 105 ++++ tests/mocks/platform/entities.ts | 120 +++++ tests/mocks/platform/functions.ts | 73 +++ tests/mocks/platform/generic.ts | 25 + tests/mocks/platform/index.ts | 171 +++++++ tests/mocks/platform/integrations.ts | 136 ++++++ tests/mocks/platform/sso.ts | 35 ++ tests/mocks/platform/state.ts | 174 +++++++ tests/mocks/server.ts | 54 +-- tests/setup.js | 22 +- tests/unit/actors.test.ts | 119 ++--- tests/unit/agents.test.ts | 204 ++++---- tests/unit/analytics.test.ts | 16 +- tests/unit/app.test.ts | 54 +-- tests/unit/auth-registration.test.ts | 69 ++- tests/unit/auth.test.js | 281 ++++------- tests/unit/client.test.js | 209 +++----- tests/unit/connectors-proxy.test.ts | 339 +++---------- tests/unit/connectors.test.ts | 290 +++--------- tests/unit/custom-integrations.test.ts | 447 +++--------------- tests/unit/entities.test.ts | 355 +++++--------- tests/unit/fetch-with-auth.test.ts | 28 +- tests/unit/functions.test.ts | 384 ++++++--------- tests/unit/integrations.test.js | 151 ++---- tests/unit/integrations.test.ts | 85 +--- tests/unit/mock-platform-architecture.test.ts | 30 ++ tests/unit/sso.test.ts | 85 +--- 34 files changed, 2187 insertions(+), 2427 deletions(-) delete mode 100644 tests/mocks/http.ts create mode 100644 tests/mocks/platform/actors.ts create mode 100644 tests/mocks/platform/agents.ts create mode 100644 tests/mocks/platform/analytics.ts create mode 100644 tests/mocks/platform/app.ts create mode 100644 tests/mocks/platform/auth.ts create mode 100644 tests/mocks/platform/connectors.ts create mode 100644 tests/mocks/platform/entities.ts create mode 100644 tests/mocks/platform/functions.ts create mode 100644 tests/mocks/platform/generic.ts create mode 100644 tests/mocks/platform/index.ts create mode 100644 tests/mocks/platform/integrations.ts create mode 100644 tests/mocks/platform/sso.ts create mode 100644 tests/mocks/platform/state.ts create mode 100644 tests/unit/mock-platform-architecture.test.ts diff --git a/tests/README.md b/tests/README.md index 70e43ff9..bf34aced 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,58 +1,56 @@ # SDK HTTP tests -`npm test` runs TypeScript API tests and the hermetic unit suite. `npm run test:coverage` reports unit coverage. Tests exercise the actual SDK HTTP clients through MSW v2; no API credentials or `tests/.env` are loaded. Unexpected traffic fails the test even when the SDK catches the network error. Never change the unit server to `warn` or `bypass` to make a test pass. +`npm test` runs the API type tests and hermetic unit suite. HTTP behavior tests call the real SDK Axios/fetch clients through one stateful MSW v2 mock Base44 platform. Tests never register handlers or author HTTP response bodies. Unexpected or unconfigured traffic fails teardown even when the SDK catches the request error. -## Add an HTTP contract +## Write a platform-backed test -Use `mockHttp` for finite request expectations. It registers native MSW handlers, captures requests, and checks bodies, headers, query parameters and call counts in teardown. This preserves request assertions on error paths: throwing directly in an MSW resolver becomes a 500 response, which an error-handling test may accidentally accept. +Arrange domain state with `platform.given`, act only through the SDK, then assert the returned behavior and any meaningful wire detail through `platform.requests`: ```ts -import { mockHttp } from '../mocks/http'; - -mockHttp({ - method: 'post', - url: 'https://api.base44.com/api/apps/test-app/entities/Todo', - body: { title: 'Write a test' }, - headers: [['authorization', 'Bearer test-token']], - status: 201, - response: { id: 'todo-1', title: 'Write a test' }, +import { platform } from "./mocks/platform"; + +platform.given.entities.records("Todo", [ + { id: "1", title: "Existing", completed: false }, +]); + +const created = await client.entities.Todo.create({ + title: "Write a test", + completed: false, +}); + +expect(await client.entities.Todo.get(created.id)).toEqual(created); +expect(await client.entities.Todo.list()).toContainEqual(created); +expect(platform.requests.last("entities.create").body).toEqual({ + title: "Write a test", + completed: false, }); -const todo = await client.entities.Todo.create({ title: 'Write a test' }); -expect(todo.id).toBe('todo-1'); ``` -Each expectation defaults to one call; set `times` for repeated requests. Register the same method/URL several times for ordered responses. URL matching is exact, including escaped operation IDs; query checks are explicit. `networkError: true` simulates a transport failure; `delayMs` tests concurrent request behavior. +Use named fault fixtures such as `platform.given.faults.functions.notFound("missing")` for error cases. Common domain results belong in reusable fixtures; unique function or integration results may be supplied as domain outcomes, but tests must not choose HTTP statuses, headers, wire envelopes, or MSW resolvers. -For multipart or binary payloads, use `inspect: async (request) => { ... }`. Parse `await request.formData()`, then assert field values, repeated keys, file names/MIME types and file bytes. These assertions are captured and rethrown in teardown, independently of the HTTP response. `body` predicates can capture JSON requests for assertions in the test. `mockHttp` is intentionally a small test fixture, not a simulation of backend business logic. +Global setup resets records, identities, deterministic identifiers, request journals and faults before and after every test. Initial handlers remain installed and `server.resetHandlers()` restores that same centralized set. Do not use concurrent tests against this singleton state. -For streams, dynamic state or other specialized behavior, use MSW directly: +## Extend the mock platform -```ts -import { http, HttpResponse } from 'msw'; -import { server } from '../mocks/server'; - -let received: unknown; -server.use(http.post('https://example.test/api/example', async ({ request }) => { - received = await request.json(); - return HttpResponse.json({ ok: true }); -})); -await clientOperation(); -expect(received).toEqual({ expected: 'payload' }); -``` +1. Confirm the SDK request and the matching backend contract. Record the exact backend revision; distinguish current apper behavior from a deliberate legacy SDK compatibility case. +2. Add state and a domain-oriented given fixture under `tests/mocks/platform/`. +3. Add or extend the module handler there. The handler owns status codes, response shapes, validation, mutations and error serialization. +4. Journal normalized requests with `recordRequest`. Multipart journal entries preserve repeated fields, filenames, MIME types, sizes and bytes. +5. Add tests that prove behavior across SDK calls (for example create → get/list) and reset isolation. Use journal assertions only for meaningful wire contracts such as auth selection, query encoding or multipart fidelity. -Keep assertions outside direct resolvers. Return explicit status codes/error bodies; do not add permissive fallback handlers. Cleanup clients with `client.cleanup()` after each test, and reset any browser globals/timers installed by the test. Global setup always removes per-test handlers and verifies expected/unexpected traffic. Tests using timers must drain pending SDK work before teardown. +Never import `msw`, `mocks/server`, or the retired `mockHttp` helper from a behavior test. Never assert inside a resolver: MSW turns resolver exceptions into HTTP 500 responses. The architecture guard enforces these boundaries. ## Coverage locations -- `entities.test.ts`: list/filter/get/create/update/delete/deleteMany/bulkCreate/updateMany, including advanced query syntax. -- `functions.test.ts`: JSON, multipart objects, caller-supplied FormData (including repeated keys and binary files), raw fetch and user/service-role headers. -- `auth.test.js`, `auth-registration.test.ts`, `sso.test.ts`: current user, login, concurrent identity transitions, registration, password reset and SSO token transport. -- `agents.test.ts`, `actors.test.ts`: agent conversations/messages and actor connection-token HTTP contracts. WebSocket constructors remain separate non-HTTP test doubles. -- `integrations.test.js`, `integrations.test.ts`, `custom-integrations.test.ts`, `connectors*.test.ts`: integration payloads/errors, tokens, scoped connections and metered proxy calls. -- `fetch-with-auth.test.ts`, `analytics.test.ts`, `app.test.ts`, `client.test.js`: fetch auth/path behavior, analytics traffic, public settings and request-derived headers. +- `entities.test.ts`: query/list/get and stateful create/update/delete/bulk/update-many behavior. +- `functions.test.ts`: JSON, multipart objects, direct FormData with repeated keys and binary bytes, raw fetch and user/service-role headers. +- `auth.test.js`, `auth-registration.test.ts`, `sso.test.ts`: current user, login and identity transitions, registration, recovery and legacy SSO compatibility. +- `agents.test.ts`, `actors.test.ts`: stateful conversations/messages and actor connection-token HTTP behavior. WebSockets remain a separate non-HTTP double. +- `integrations*.test.*`, `custom-integrations.test.ts`, `connectors*.test.ts`: domain outcomes, custom upstream envelopes, scoped tokens and proxy calls. +- `fetch-with-auth.test.ts`, `analytics.test.ts`, `app.test.ts`, `client.test.js`: fetch auth/path behavior, analytics batches, public settings and request-derived headers. -The fixtures are grounded in current SDK wire contracts, not a claim that every real backend route has been independently validated. Live E2E tests remain a separate check. +The centralized contracts are based on pinned backend source plus explicitly labeled SDK compatibility behavior. They do not prove the currently deployed production version. Live E2E remains separate. ## Explicit live E2E tests -`BASE44_RUN_E2E=true npm run test:e2e` uses `vitest.e2e.config.ts`, loads `tests/.env`, and bypasses MSW entirely. Supply a dedicated disposable test application via `BASE44_SERVER_URL`, `BASE44_APP_ID`, and `BASE44_AUTH_TOKEN`. These tests can create/delete platform data. They are excluded from `npm test` and unit coverage. Running `npm run test:e2e` without opt-in fails before tests or network calls begin. +`BASE44_RUN_E2E=true npm run test:e2e` uses `vitest.e2e.config.ts`, loads `tests/.env`, and bypasses MSW. Supply a disposable test app via `BASE44_SERVER_URL`, `BASE44_APP_ID`, and `BASE44_AUTH_TOKEN`. These tests can mutate real data and are excluded from `npm test` and unit coverage. Running without the opt-in fails before network calls begin. diff --git a/tests/mocks/http.ts b/tests/mocks/http.ts deleted file mode 100644 index 20d0b08a..00000000 --- a/tests/mocks/http.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { expect } from "vitest"; -import { http, HttpResponse, delay } from "msw"; -import { server } from "./server"; - -type HeaderExpectation = - string | RegExp | ((value: string | undefined) => boolean); -interface HttpExpectation { - method: "get" | "post" | "put" | "patch" | "delete"; - url: string; - body?: any; - inspect?: (request: Request) => void | Promise; - query?: - | true - | Record - | ((query: Record) => boolean); - headers?: [string, HeaderExpectation][]; - reqheaders?: Record; - badheaders?: string[]; - times?: number; - delayMs?: number; - networkError?: boolean; - status?: number; - response?: any; - responseHeaders?: Record; - respond?: (request: Request) => [number, any]; -} -interface Capture { - request: Request; - body: unknown; -} -const expectations: { - expected: HttpExpectation; - requests: Capture[]; - failures: unknown[]; -}[] = []; - -/** A native MSW handler with after-test request contract verification. - * Resolver assertions cannot fail a test reliably: MSW translates exceptions - * into HTTP 500 responses. Capturing them separately also covers error paths. - * Repeated registrations for one route form a response sequence, in order. - */ -export function mockHttp(expected: HttpExpectation) { - expectations.push({ expected, requests: [], failures: [] }); - // Use an exact regex: operation IDs may contain MSW path-pattern metacharacters. - const url = new RegExp( - "^" + expected.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?:\\?.*)?$", - ); - server.use( - http[expected.method](url, async ({ request }) => { - const sameRoute = expectations.filter( - (item) => - item.expected.method === expected.method && - item.expected.url === expected.url, - ); - const item = - sameRoute.find( - (item) => item.requests.length < (item.expected.times ?? 1), - ) ?? sameRoute.at(-1)!; - const rule = item.expected; - const text = request.body ? await request.clone().text() : ""; - let body: unknown = text; - if ( - text && - request.headers.get("content-type")?.includes("application/json") - ) - body = JSON.parse(text); - item.requests.push({ request, body }); - if (typeof rule.body === "function") { - try { - expect(rule.body(body)).toBe(true); - } catch (error) { - item.failures.push(error); - } - } - if (rule.inspect) { - try { - await rule.inspect(request.clone()); - } catch (error) { - item.failures.push(error); - } - } - if (rule.delayMs) await delay(rule.delayMs); - if (rule.networkError) return HttpResponse.error(); - const [status, response] = rule.respond?.(request) ?? [ - rule.status ?? 200, - rule.response, - ]; - const init = { status, headers: rule.responseHeaders }; - return typeof response === "string" - ? new HttpResponse(response, init) - : HttpResponse.json(response, init); - }), - ); -} - -export function verifyHttpExpectations() { - const pending = expectations.splice(0); - for (const { expected, requests, failures } of pending) { - if (failures.length) throw failures[0]; - expect( - requests, - `${expected.method.toUpperCase()} ${expected.url} request count`, - ).toHaveLength(expected.times ?? 1); - for (const { request, body } of requests) { - if ("body" in expected && typeof expected.body !== "function") - expect(body).toEqual(expected.body); - if (expected.query && expected.query !== true) { - const query = Object.fromEntries(new URL(request.url).searchParams); - if (typeof expected.query === "function") - expect(expected.query(query)).toBe(true); - else expect(query).toEqual(expected.query); - } - for (const [name, value] of [ - ...Object.entries(expected.reqheaders ?? {}), - ...(expected.headers ?? []), - ]) { - const actual = request.headers.get(name) ?? undefined; - if (typeof value === "function") expect(value(actual)).toBe(true); - else if (value instanceof RegExp) expect(actual).toMatch(value); - else expect(actual, `header ${name}`).toBe(value); - } - for (const name of expected.badheaders ?? []) - expect(request.headers.has(name), `absent header ${name}`).toBe(false); - } - } -} diff --git a/tests/mocks/platform/actors.ts b/tests/mocks/platform/actors.ts new file mode 100644 index 00000000..c2192a74 --- /dev/null +++ b/tests/mocks/platform/actors.ts @@ -0,0 +1,49 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest } from "./state"; + +interface ActorConfig { + websocketUrl: string; + token: string; + expiresAt: string; + mode: "prod" | "preview"; +} +type ActorFault = "legacy-conflict" | "endpoint-unsupported" | "mint-failed"; +const actors = new Map(); +const faults = new Map(); + +export const actorFixtures = { + available(name: string, config: ActorConfig) { + actors.set(name, structuredClone(config)); + }, + fault(name: string, fault: ActorFault) { + faults.set(name, fault); + }, +}; + +export function resetActorState() { + actors.clear(); + faults.clear(); +} + +export const actorHandlers = [ + http.post("*/api/apps/:appId/actors/:actorName/connection-token", async ({ params, request }) => { + await recordRequest("actors.mintConnectionToken", request); + const name = String(params.actorName); + const fault = faults.get(name); + if (fault === "legacy-conflict") + return HttpResponse.json({ message: "Actor must be migrated before connecting directly" }, { status: 409 }); + if (fault === "endpoint-unsupported") + return HttpResponse.json({ error_type: "HTTPException", message: "Method Not Allowed", detail: "Method Not Allowed" }, { status: 405 }); + if (fault === "mint-failed") + return HttpResponse.json({ message: "mint exploded" }, { status: 500 }); + const config = actors.get(name); + return config + ? HttpResponse.json({ + websocket_url: config.websocketUrl, + token: config.token, + expires_at: config.expiresAt, + mode: config.mode, + }) + : HttpResponse.json({ detail: "Actor not found", code: "NOT_FOUND" }, { status: 404 }); + }), +]; diff --git a/tests/mocks/platform/agents.ts b/tests/mocks/platform/agents.ts new file mode 100644 index 00000000..954f0cc3 --- /dev/null +++ b/tests/mocks/platform/agents.ts @@ -0,0 +1,48 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest, state, type PlatformConversation } from "./state"; + +// Pinned Apper d9ae151 enforces caller ownership/visitor rules, strips +// reserved metadata, and redacts public conversations. This in-memory model +// intentionally covers SDK-visible conversation transitions only; it does not +// claim to reproduce that authorization and redaction policy. +export const agentHandlers = [ + http.get("*/api/apps/:appId/agents/conversations", async ({ request }) => { + await recordRequest("agents.listConversations", request); + return HttpResponse.json(state.conversations); + }), + http.get("*/api/apps/:appId/agents/conversations/:conversationId", async ({ params, request }) => { + await recordRequest("agents.getConversation", request); + const conversation = state.conversations.find( + (item) => item.id === params.conversationId, + ); + return conversation + ? HttpResponse.json(conversation) + : HttpResponse.json({ detail: "Conversation not found", code: "NOT_FOUND" }, { status: 404 }); + }), + http.post("*/api/apps/:appId/agents/conversations", async ({ request }) => { + await recordRequest("agents.createConversation", request); + const input = (await request.clone().json()) as Pick; + const conversation: PlatformConversation = { + id: `conv-${state.nextConversationId++}`, + agent_name: input.agent_name, + messages: [], + }; + state.conversations.push(conversation); + return HttpResponse.json(conversation); + }), + http.post( + "*/api/apps/:appId/agents/conversations/v2/:conversationId/messages", + async ({ params, request }) => { + await recordRequest("agents.addMessage", request); + const input = (await request.clone().json()) as Record; + const conversation = state.conversations.find( + (item) => item.id === params.conversationId, + ); + if (!conversation) + return HttpResponse.json({ detail: "Conversation not found", code: "NOT_FOUND" }, { status: 404 }); + const message = { id: `msg-${state.nextMessageId++}`, ...input }; + conversation.messages.push(message); + return HttpResponse.json(message); + }, + ), +]; diff --git a/tests/mocks/platform/analytics.ts b/tests/mocks/platform/analytics.ts new file mode 100644 index 00000000..24b3e816 --- /dev/null +++ b/tests/mocks/platform/analytics.ts @@ -0,0 +1,10 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest } from "./state"; + +export const analyticsHandlers = [ + http.post("*/api/apps/:appId/analytics/track/batch", async ({ request }) => { + await recordRequest("analytics.trackBatch", request); + const recorded = (await request.clone().json()) as { events?: unknown[] }; + return HttpResponse.json({ accepted: recorded.events?.length ?? 0 }); + }), +]; diff --git a/tests/mocks/platform/app.ts b/tests/mocks/platform/app.ts new file mode 100644 index 00000000..1ea03bd3 --- /dev/null +++ b/tests/mocks/platform/app.ts @@ -0,0 +1,37 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest } from "./state"; + +type PublicSettings = { id: string; public_settings: string }; + +const settings = new Map(); +const accessFaults = new Map(); + +export const appFixtures = { + publicSettings(value: PublicSettings) { + settings.set(value.id, structuredClone(value)); + }, + /** Legacy edge response retained to verify SDK error compatibility; current + * apper's deployment settings route documents 404/500 instead. */ + legacyAccessDenied(appId: string, reason: "auth_required" | "user_not_registered") { + accessFaults.set(appId, reason); + }, +}; + +export function resetAppState() { + settings.clear(); + accessFaults.clear(); +} + +export const appHandlers = [ + http.get("*/api/apps/public/prod/public-settings/by-id/:appId", async ({ params, request }) => { + await recordRequest("app.getPublicSettings", request); + const appId = String(params.appId); + const reason = accessFaults.get(appId); + if (reason) + return HttpResponse.json({ extra_data: { app_id: appId, reason } }, { status: 403 }); + const value = settings.get(appId); + return value + ? HttpResponse.json({ id: value.id, public_settings: value.public_settings }) + : HttpResponse.json({ detail: "App not found", code: "NOT_FOUND" }, { status: 404 }); + }), +]; diff --git a/tests/mocks/platform/auth.ts b/tests/mocks/platform/auth.ts new file mode 100644 index 00000000..b1a58daa --- /dev/null +++ b/tests/mocks/platform/auth.ts @@ -0,0 +1,209 @@ +import { delay, http, HttpResponse } from "msw"; +import { recordRequest, state, type PlatformFault } from "./state"; + +type User = Record & { id: string }; +type MeOutcome = + | { kind: "user"; user: User; delayMs?: number } + | { kind: "unauthorized"; delayMs?: number } + | { kind: "network-error"; delayMs?: number }; + +interface LoginAccount { + email: string; + password: string; + accessToken: string; + user: User; + countryCode?: string; +} + +let currentUser: User | null = null; +let meOutcomes: MeOutcome[] = []; +let loginAccounts: LoginAccount[] = []; +let rejectedUpdates = 0; +let invalidLogins = new Set(); +let unavailableLogins = new Set(); + +export function resetAuthState() { + currentUser = null; + meOutcomes = []; + loginAccounts = []; + rejectedUpdates = 0; + invalidLogins = new Set(); + unavailableLogins = new Set(); +} + +export const authFixtures = { + user(user: User) { + currentUser = structuredClone(user); + }, + meSequence(outcomes: ({ user: User } | { unauthorized: true } | { networkError: true })[], delayMs = 0) { + meOutcomes = outcomes.map((outcome) => { + if ("user" in outcome) + return { kind: "user", user: structuredClone(outcome.user), delayMs }; + if ("unauthorized" in outcome) + return { kind: "unauthorized", delayMs }; + return { kind: "network-error", delayMs }; + }); + }, + login(account: LoginAccount) { + loginAccounts.push(structuredClone(account)); + }, +}; + +export const authFaultFixtures = { + unauthorizedMe() { + meOutcomes.push({ kind: "unauthorized" }); + }, + networkUnavailableMe() { + meOutcomes.push({ kind: "network-error" }); + }, + rejectedUpdate() { + rejectedUpdates += 1; + }, + invalidCredentials(email: string) { + invalidLogins.add(email); + }, + networkUnavailableLogin(email: string) { + unavailableLogins.add(email); + }, +}; + +function takeFault(predicate: (fault: PlatformFault) => boolean) { + const index = state.faults.findIndex(predicate); + if (index < 0) return false; + state.faults.splice(index, 1); + return true; +} + +export const authHandlers = [ + http.get( + "*/api/apps/:appId/entities/User/me", + async ({ request }) => { + await recordRequest("auth.me", request); + const outcome = meOutcomes.shift(); + if (outcome?.delayMs) await delay(outcome.delayMs); + if (outcome?.kind === "network-error") return HttpResponse.error(); + if (outcome?.kind === "unauthorized" || (!outcome && !currentUser)) + return HttpResponse.json({ detail: "Unauthorized" }, { status: 401 }); + const user = outcome?.kind === "user" ? outcome.user : currentUser!; + currentUser = structuredClone(user); + return HttpResponse.json(user); + }, + ), + http.put( + "*/api/apps/:appId/entities/User/me", + async ({ request }) => { + await recordRequest("auth.updateMe", request); + if (rejectedUpdates > 0) { + rejectedUpdates -= 1; + return HttpResponse.json( + { detail: "Invalid email format" }, + { status: 400 }, + ); + } + const updates = (await request.clone().json()) as Record; + currentUser = { ...(currentUser ?? { id: "user-1" }), ...updates } as User; + return HttpResponse.json(currentUser); + }, + ), + http.post( + "*/api/apps/:appId/auth/login", + async ({ request }) => { + await recordRequest("auth.login", request); + const body = (await request.clone().json()) as { + email: string; + password: string; + }; + if (unavailableLogins.delete(body.email)) return HttpResponse.error(); + if (invalidLogins.delete(body.email)) + return HttpResponse.json( + { detail: "Invalid credentials" }, + { status: 400 }, + ); + const account = loginAccounts.find( + (candidate) => + candidate.email === body.email && candidate.password === body.password, + ); + if (!account) + return HttpResponse.json( + { detail: "Invalid credentials" }, + { status: 400 }, + ); + currentUser = structuredClone(account.user); + return HttpResponse.json({ + access_token: account.accessToken, + country_code: account.countryCode ?? null, + success: true, + user: account.user, + }); + }, + ), + http.post( + "*/api/apps/:appId/auth/register", + async ({ request }) => { + await recordRequest("auth.register", request); + const body = (await request.clone().json()) as { email: string }; + if ( + takeFault( + (fault) => + fault.kind === "auth-registration-rejected" && + fault.email === body.email, + ) + ) { + return HttpResponse.json( + { detail: "Registration rejected" }, + { status: 400 }, + ); + } + const registration = state.registrations.get(body.email); + if (!registration) + return HttpResponse.json( + { detail: "Registration fixture not found" }, + { status: 404 }, + ); + return HttpResponse.json({ + id: registration.id, + message: registration.message, + otp_expires_in_minutes: registration.otpExpiresInMinutes, + country_code: registration.countryCode, + }); + }, + ), + http.post( + "*/api/apps/:appId/auth/reset-password-request", + async ({ request }) => { + await recordRequest("auth.resetPasswordRequest", request); + const body = (await request.clone().json()) as { email: string }; + return HttpResponse.json({ + message: + state.passwordResetRequestMessages.get(body.email) ?? + "Request accepted", + }); + }, + ), + http.post( + "*/api/apps/:appId/auth/reset-password", + async ({ request }) => { + await recordRequest("auth.resetPassword", request); + const body = (await request.clone().json()) as { reset_token: string }; + if ( + takeFault( + (fault) => + fault.kind === "auth-reset-token-expired" && + fault.resetToken === body.reset_token, + ) + ) { + return HttpResponse.json( + { detail: "Reset token expired" }, + { status: 400 }, + ); + } + const user = state.passwordResetUsers.get(body.reset_token); + return user + ? HttpResponse.json(structuredClone(user)) + : HttpResponse.json( + { detail: "Reset token invalid" }, + { status: 400 }, + ); + }, + ), +]; diff --git a/tests/mocks/platform/connectors.ts b/tests/mocks/platform/connectors.ts new file mode 100644 index 00000000..bb74cd79 --- /dev/null +++ b/tests/mocks/platform/connectors.ts @@ -0,0 +1,105 @@ +import { http, HttpResponse } from "msw"; +import { + recordRequest, + state, + type ConnectorProxyOutcome, + type ConnectorToken, + type PlatformFault, +} from "./state"; + +function takeFault(predicate: (fault: PlatformFault) => boolean) { + const index = state.faults.findIndex(predicate); + if (index < 0) return false; + state.faults.splice(index, 1); + return true; +} + +function token(integrationType: string, accessToken: string, connectionConfig?: Record | null): ConnectorToken { + return { integrationType, accessToken, ...(connectionConfig === undefined ? {} : { connectionConfig }) }; +} + +export const connectorFixtures = { + connection(integrationType: string, accessToken: string, connectionConfig?: Record | null) { + state.connectorTokens.set(integrationType, token(integrationType, accessToken, connectionConfig)); + }, + workspaceConnection(connectorId: string, integrationType: string, accessToken: string, connectionConfig?: Record | null) { + state.workspaceConnectorTokens.set(connectorId, token(integrationType, accessToken, connectionConfig)); + }, + appUserConnection(connectorId: string, integrationType: string, accessToken: string, connectionConfig?: Record | null) { + state.appUserConnectorTokens.set(connectorId, token(integrationType, accessToken, connectionConfig)); + }, + proxyOutcome(integrationType: string, outcome: ConnectorProxyOutcome) { + state.connectorProxyOutcomes.set(integrationType, structuredClone(outcome)); + }, +}; + +export const connectorFaultFixtures = { + creditsExhausted(integrationType: string) { + state.faults.push({ kind: "connector-credits-exhausted", integrationType }); + }, + meteredTokenRequiresProxy(integrationType: string) { + state.faults.push({ kind: "metered-connector-token-refused", integrationType }); + }, +}; + +function tokenResponse(value: ConnectorToken | undefined) { + if (!value) + return HttpResponse.json({ detail: "Connector connection not found", code: "NOT_FOUND" }, { status: 404 }); + return HttpResponse.json({ + access_token: value.accessToken, + integration_type: value.integrationType, + ...(value.connectionConfig === undefined ? {} : { connection_config: value.connectionConfig }), + }); +} + +export const connectorHandlers = [ + http.get("*/api/apps/:appId/external-auth/tokens/connectors/:connectorId", async ({ params, request }) => { + await recordRequest("connectors.getWorkspaceConnection", request); + return tokenResponse(state.workspaceConnectorTokens.get(String(params.connectorId))); + }), + http.get("*/api/apps/:appId/external-auth/tokens/:integrationType", async ({ params, request }) => { + await recordRequest("connectors.getConnection", request); + const integrationType = String(params.integrationType); + const metered = takeFault( + (item) => item.kind === "metered-connector-token-refused" && item.integrationType === integrationType, + ); + if (metered) + return HttpResponse.json( + { detail: `Connector '${integrationType}' is metered — raw access tokens are not available for it. Call POST /api/apps/${String(params.appId)}/connectors/${integrationType}/call instead.` }, + { status: 403, headers: { "X-Base44-Connector-Error": "metered_connector_requires_proxy" } }, + ); + return tokenResponse(state.connectorTokens.get(integrationType)); + }), + http.get("*/api/apps/:appId/app-user-auth/connectors/:connectorId/token", async ({ params, request }) => { + await recordRequest("connectors.getCurrentAppUserConnection", request); + return tokenResponse(state.appUserConnectorTokens.get(String(params.connectorId))); + }), + http.post("*/api/apps/:appId/connectors/:integrationType/call", async ({ params, request }) => { + await recordRequest("connectors.callApi", request); + const integrationType = String(params.integrationType); + const exhausted = takeFault( + (item) => item.kind === "connector-credits-exhausted" && item.integrationType === integrationType, + ); + if (exhausted) + return HttpResponse.json( + { + message: "You have reached the limit of integrations for this month", + extra_data: { reason: "integration_credits_limit_reached" }, + }, + { status: 402 }, + ); + const outcome = state.connectorProxyOutcomes.get(integrationType); + if (!outcome) + return HttpResponse.json({ detail: "Connector proxy not configured", code: "NOT_FOUND" }, { status: 404 }); + return HttpResponse.json({ + success: outcome.success, + phase: outcome.phase, + status_code: outcome.status, + data: outcome.data, + ...(outcome.dataBase64 === undefined ? {} : { data_base64: outcome.dataBase64 }), + ...(outcome.contentType === undefined ? {} : { content_type: outcome.contentType }), + headers: outcome.headers ?? {}, + credits_charged: outcome.creditsCharged ?? 0, + }); + }), +]; diff --git a/tests/mocks/platform/entities.ts b/tests/mocks/platform/entities.ts new file mode 100644 index 00000000..ca7bc0f1 --- /dev/null +++ b/tests/mocks/platform/entities.ts @@ -0,0 +1,120 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest, state, type PlatformRecord } from "./state"; + +function matches(record: PlatformRecord, query: Record): boolean { + return Object.entries(query).every(([field, expected]) => { + if (field === "$or") + return expected.some((alternative: Record) => + matches(record, alternative), + ); + if (expected && typeof expected === "object" && "$in" in expected) + return expected.$in.includes(record[field]); + return record[field] === expected; + }); +} + +function collection(entityName: string) { + let records = state.entities.get(entityName); + if (!records) { + records = []; + state.entities.set(entityName, records); + } + return records; +} + +function select(records: PlatformRecord[], request: Request) { + const search = new URL(request.url).searchParams; + const query = search.get("q"); + let selected = query ? records.filter((item) => matches(item, JSON.parse(query))) : [...records]; + const sort = search.get("sort"); + if (sort) { + const descending = sort.startsWith("-"); + const field = descending ? sort.slice(1) : sort; + selected.sort((left, right) => { + const comparison = String(left[field]).localeCompare(String(right[field])); + return descending ? -comparison : comparison; + }); + } + const skip = Number(search.get("skip") ?? 0); + const limit = Number(search.get("limit") ?? selected.length); + selected = selected.slice(skip, skip + limit); + const fields = search.get("fields")?.split(","); + if (fields) { + const projectedFields = ["id", ...fields.filter((field) => field !== "id")]; + selected = selected.map((item) => + Object.fromEntries(projectedFields.map((field) => [field, item[field]])), + ) as PlatformRecord[]; + } + return selected; +} + +export const entityHandlers = [ + http.get("*/api/apps/:appId/entities/:entityName", async ({ params, request }) => { + await recordRequest("entities.list", request); + return HttpResponse.json(select(collection(String(params.entityName)), request)); + }), + http.post("*/api/apps/:appId/entities/:entityName", async ({ params, request }) => { + await recordRequest("entities.create", request); + const input = (await request.clone().json()) as Record; + const created = { id: String(state.nextEntityId++), ...input }; + collection(String(params.entityName)).push(created); + return HttpResponse.json(created, { status: 201 }); + }), + http.get("*/api/apps/:appId/entities/:entityName/:id", async ({ params, request }) => { + await recordRequest("entities.get", request); + const found = collection(String(params.entityName)).find( + (item) => item.id === params.id, + ); + return found + ? HttpResponse.json(found) + : HttpResponse.json({ detail: "Entity not found", code: "NOT_FOUND" }, { status: 404 }); + }), + http.put("*/api/apps/:appId/entities/:entityName/bulk", async ({ params, request }) => { + await recordRequest("entities.bulkUpdate", request); + const updates = (await request.clone().json()) as PlatformRecord[]; + const records = collection(String(params.entityName)); + const changed = updates.map((update) => { + const index = records.findIndex((item) => item.id === update.id); + if (index < 0) return update; + records[index] = { ...records[index], ...update }; + return records[index]; + }); + return HttpResponse.json(changed); + }), + http.put("*/api/apps/:appId/entities/:entityName/:id", async ({ params, request }) => { + await recordRequest("entities.update", request); + const updates = (await request.clone().json()) as Record; + const records = collection(String(params.entityName)); + const index = records.findIndex((item) => item.id === params.id); + if (index < 0) + return HttpResponse.json({ detail: "Entity not found", code: "NOT_FOUND" }, { status: 404 }); + records[index] = { ...records[index], ...updates }; + return HttpResponse.json(records[index]); + }), + http.delete("*/api/apps/:appId/entities/:entityName/:id", async ({ params, request }) => { + await recordRequest("entities.delete", request); + const records = collection(String(params.entityName)); + const index = records.findIndex((item) => item.id === params.id); + if (index >= 0) records.splice(index, 1); + return HttpResponse.json({ success: index >= 0 }); + }), + http.patch("*/api/apps/:appId/entities/:entityName/update-many", async ({ params, request }) => { + await recordRequest("entities.updateMany", request); + const { query, data } = (await request.clone().json()) as { + query: Record; + data: { $set?: Record; $inc?: Record }; + }; + const matching = collection(String(params.entityName)).filter((item) => matches(item, query)); + const selected = matching.slice(0, 500); + for (const item of selected) { + Object.assign(item, data.$set ?? {}); + for (const [field, amount] of Object.entries(data.$inc ?? {})) + item[field] = Number(item[field] ?? 0) + amount; + } + return HttpResponse.json({ + success: true, + updated: selected.length, + has_more: matching.length > selected.length, + }); + }), +]; diff --git a/tests/mocks/platform/functions.ts b/tests/mocks/platform/functions.ts new file mode 100644 index 00000000..a0812df1 --- /dev/null +++ b/tests/mocks/platform/functions.ts @@ -0,0 +1,73 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest, state, type PlatformFault } from "./state"; + +function takeFault(predicate: (fault: PlatformFault) => boolean) { + const index = state.faults.findIndex(predicate); + if (index < 0) return false; + state.faults.splice(index, 1); + return true; +} + +export const functionHandlers = [ + http.post( + "*/api/apps/:appId/functions/:functionName", + async ({ params, request }) => { + await recordRequest("functions.invoke", request); + const functionName = String(params.functionName); + if ( + takeFault( + (fault) => + fault.kind === "function-network-unavailable" && + fault.functionName === functionName, + ) + ) { + return HttpResponse.error(); + } + if ( + takeFault( + (fault) => + fault.kind === "function-internal-error" && + fault.functionName === functionName, + ) + ) { + return HttpResponse.json( + { error: "Internal server error", code: "INTERNAL_ERROR" }, + { status: 500 }, + ); + } + if ( + takeFault( + (fault) => + fault.kind === "function-not-found" && + fault.functionName === functionName, + ) || + !state.functionResults.has(functionName) + ) { + return HttpResponse.json( + { error: "Function not found", code: "FUNCTION_NOT_FOUND" }, + { status: 404 }, + ); + } + return HttpResponse.json(state.functionResults.get(functionName)); + }, + ), + // The SDK also exposes this legacy, non-app-scoped alias. It is not present + // in the pinned apper route surface, so this models only the SDK's transport + // contract rather than claiming a verified backend response contract. + http.all("*/api/functions/*", async ({ request }) => { + await recordRequest("functions.fetch", request); + const marker = "/api/functions/"; + const path = decodeURIComponent( + new URL(request.url).pathname.slice( + new URL(request.url).pathname.indexOf(marker) + marker.length, + ), + ); + if (!state.rawFunctions.has(path)) { + return HttpResponse.json( + { detail: "Function not found" }, + { status: 404 }, + ); + } + return new HttpResponse(state.rawFunctions.get(path), { status: 200 }); + }), +]; diff --git a/tests/mocks/platform/generic.ts b/tests/mocks/platform/generic.ts new file mode 100644 index 00000000..8884d919 --- /dev/null +++ b/tests/mocks/platform/generic.ts @@ -0,0 +1,25 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest } from "./state"; + +const paths = new Set(); + +export const genericFixtures = { + route(path: string) { + paths.add(path.split(/[?#]/, 1)[0]!); + }, +}; + +export function resetGenericState() { + paths.clear(); +} + +export const genericHandlers = [ + http.all("*/api/*", async ({ request }) => { + const pathname = new URL(request.url).pathname; + const route = paths.has(pathname) ? "generic.request" : "platform.unconfigured"; + await recordRequest(route, request); + return route === "generic.request" + ? HttpResponse.json({}) + : HttpResponse.json({ detail: `No mock platform route configured for ${pathname}` }, { status: 404 }); + }), +]; diff --git a/tests/mocks/platform/index.ts b/tests/mocks/platform/index.ts new file mode 100644 index 00000000..667e13b9 --- /dev/null +++ b/tests/mocks/platform/index.ts @@ -0,0 +1,171 @@ +import { actorFixtures, actorHandlers, resetActorState } from "./actors"; +import { agentHandlers } from "./agents"; +import { analyticsHandlers } from "./analytics"; +import { appFixtures, appHandlers, resetAppState } from "./app"; +import { + authFaultFixtures, + authFixtures, + authHandlers, + resetAuthState, +} from "./auth"; +import { + connectorFaultFixtures, + connectorFixtures, + connectorHandlers, +} from "./connectors"; +import { entityHandlers } from "./entities"; +import { functionHandlers } from "./functions"; +import { genericFixtures, genericHandlers, resetGenericState } from "./generic"; +import { + customIntegrationFaultFixtures, + customIntegrationFixtures, + integrationFaultFixtures, + integrationFixtures, + integrationHandlers, +} from "./integrations"; +import { resetSsoState, ssoFixtures, ssoHandlers } from "./sso"; +import { + resetPlatformState, + state, + type PlatformConversation, + type PlatformRegistration, + type PlatformRecord, + type RecordedRequest, +} from "./state"; + +function clone(value: T): T { + return structuredClone(value); +} + +export const platformHandlers = [ + ...authHandlers, + ...entityHandlers, + ...agentHandlers, + ...functionHandlers, + ...integrationHandlers, + ...connectorHandlers, + ...analyticsHandlers, + ...actorHandlers, + ...appHandlers, + ...ssoHandlers, + ...genericHandlers, +]; + +function reset() { + resetPlatformState(); + resetAuthState(); + resetActorState(); + resetAppState(); + resetSsoState(); + resetGenericState(); +} + +export const platform = { + reset, + given: { + entities: { + records(entityName: string, records: PlatformRecord[]) { + state.entities.set(entityName, clone(records)); + const numericIds = records + .map((record) => Number(record.id)) + .filter(Number.isFinite); + state.nextEntityId = Math.max( + state.nextEntityId, + ...numericIds.map((id) => id + 1), + ); + }, + }, + agents: { + conversations(conversations: PlatformConversation[]) { + state.conversations = clone(conversations); + const numericIds = conversations + .map((conversation) => Number(conversation.id.match(/\d+$/)?.[0])) + .filter(Number.isFinite); + state.nextConversationId = Math.max( + state.nextConversationId, + ...numericIds.map((id) => id + 1), + ); + }, + }, + auth: { + ...authFixtures, + registration(email: string, registration: PlatformRegistration) { + state.registrations.set(email, clone(registration)); + }, + passwordResetRequest(email: string, message = "Request accepted") { + state.passwordResetRequestMessages.set(email, message); + }, + passwordReset(resetToken: string, user: PlatformRecord) { + state.passwordResetUsers.set(resetToken, clone(user)); + }, + }, + functions: { + result(functionName: string, result: unknown) { + state.functionResults.set(functionName, clone(result)); + }, + raw(functionPath: string, response = "ok") { + state.rawFunctions.set(functionPath.replace(/^\//, ""), response); + }, + }, + integrations: integrationFixtures, + customIntegrations: customIntegrationFixtures, + connectors: connectorFixtures, + actors: actorFixtures, + app: appFixtures, + sso: ssoFixtures, + generic: genericFixtures, + faults: { + auth: { + ...authFaultFixtures, + registrationRejected(email: string) { + state.faults.push({ kind: "auth-registration-rejected", email }); + }, + resetTokenExpired(resetToken: string) { + state.faults.push({ kind: "auth-reset-token-expired", resetToken }); + }, + }, + functions: { + internalError(functionName: string) { + state.faults.push({ kind: "function-internal-error", functionName }); + }, + notFound(functionName: string) { + state.faults.push({ kind: "function-not-found", functionName }); + }, + networkUnavailable(functionName: string) { + state.faults.push({ + kind: "function-network-unavailable", + functionName, + }); + }, + }, + integrations: integrationFaultFixtures, + customIntegrations: customIntegrationFaultFixtures, + connectors: connectorFaultFixtures, + }, + }, + requests: { + all(route?: string): RecordedRequest[] { + const requests = route + ? state.requests.filter((request) => request.route === route) + : state.requests; + return clone(requests); + }, + last(route: string): RecordedRequest { + const request = state.requests.findLast((item) => item.route === route); + if (!request) throw new Error(`No request recorded for ${route}`); + return clone(request); + }, + count(route: string): number { + return state.requests.filter((request) => request.route === route).length; + }, + }, +}; + +export type { + MultipartBody, + MultipartEntry, + PlatformConversation, + PlatformRegistration, + PlatformRecord, + RecordedRequest, +} from "./state"; diff --git a/tests/mocks/platform/integrations.ts b/tests/mocks/platform/integrations.ts new file mode 100644 index 00000000..4f2a146f --- /dev/null +++ b/tests/mocks/platform/integrations.ts @@ -0,0 +1,136 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest, state, type PlatformFault } from "./state"; + +const endpointKey = (packageName: string, endpointName: string) => + `${packageName}:${endpointName}`; + +function takeFault(predicate: (fault: PlatformFault) => boolean) { + const index = state.faults.findIndex(predicate); + if (index < 0) return false; + state.faults.splice(index, 1); + return true; +} + +export const integrationFixtures = { + packageSucceeds(packageName: string, endpointName: string, result: Record = {}) { + state.integrationEndpoints.set(endpointKey(packageName, endpointName), { + response: { success: true, ...result }, + }); + }, + emailDelivered(messageId = "123456") { + this.packageSucceeds("Core", "SendEmail", { messageId }); + }, + fileUploaded(fileId = "file123") { + this.packageSucceeds("Core", "UploadFile", { fileId }); + }, + llmResponds(response: unknown) { + state.integrationEndpoints.set(endpointKey("Core", "InvokeLLM"), { response }); + }, +}; + +export const integrationFaultFixtures = { + invalidParameters(packageName: string, endpointName: string) { + state.faults.push({ kind: "integration-invalid-parameters", packageName, endpointName }); + }, +}; + +function invokeIntegration(packageName: string, endpointName: string) { + const fault = takeFault( + (item) => + item.kind === "integration-invalid-parameters" && + item.packageName === packageName && + item.endpointName === endpointName, + ); + if (fault) + return HttpResponse.json( + { detail: "Invalid parameters", code: "INVALID_PARAMS" }, + { status: 400 }, + ); + const endpoint = state.integrationEndpoints.get(endpointKey(packageName, endpointName)); + if (!endpoint) + return HttpResponse.json( + { detail: `Integration endpoint '${packageName}.${endpointName}' not found`, code: "NOT_FOUND" }, + { status: 404 }, + ); + return HttpResponse.json(endpoint.response); +} + +function parseCustomRoute(request: Request) { + const match = new URL(request.url).pathname.match( + /^\/api\/apps\/[^/]+\/integrations\/custom\/([^/]+)\/(.+)$/, + ); + if (!match) return undefined; + return { slug: decodeURIComponent(match[1]), operationId: decodeURIComponent(match[2]) }; +} + +export const customIntegrationFixtures = { + operation(slug: string, operationId: string, data: unknown, statusCode = 200) { + let operations = state.customIntegrations.get(slug); + if (!operations) { + operations = new Map(); + state.customIntegrations.set(slug, operations); + } + operations.set(operationId, { data, statusCode }); + }, +}; + +export const customIntegrationFaultFixtures = { + upstreamUnavailable(slug: string, operationId: string) { + state.faults.push({ kind: "custom-upstream-unavailable", slug, operationId }); + }, +}; + +export const integrationHandlers = [ + http.post( + "*/api/apps/:appId/integration-endpoints/Core/:endpointName", + async ({ params, request }) => { + await recordRequest("integrations.invoke", request); + return invokeIntegration("Core", String(params.endpointName)); + }, + ), + http.post( + // Legacy SDK compatibility: current Apper no longer exposes installable-package + // integrations, but the SDK still promises this dynamic package route. + "*/api/apps/:appId/integration-endpoints/installable/:packageName/integration-endpoints/:endpointName", + async ({ params, request }) => { + await recordRequest("integrations.invoke", request); + return invokeIntegration(String(params.packageName), String(params.endpointName)); + }, + ), + http.post(/^https?:\/\/[^/]+\/api\/apps\/[^/]+\/integrations\/custom\/.+$/, async ({ request }) => { + await recordRequest("customIntegrations.call", request); + const route = parseCustomRoute(request); + if (!route) + return HttpResponse.json({ detail: "Custom integration route not found" }, { status: 404 }); + const { slug, operationId } = route; + const upstreamFault = takeFault( + (item) => + item.kind === "custom-upstream-unavailable" && + item.slug === slug && + item.operationId === operationId, + ); + if (upstreamFault) + return HttpResponse.json({ + success: false, + status_code: 502, + data: { detail: "Failed to connect to external API: Connection refused" }, + }); + const operations = state.customIntegrations.get(slug); + if (!operations) + return HttpResponse.json( + { detail: `Custom integration '${slug}' not found in workspace` }, + { status: 404 }, + ); + const operation = operations.get(operationId); + if (!operation) + return HttpResponse.json( + { detail: `Operation '${operationId}' not found in integration '${slug}'` }, + { status: 404 }, + ); + return HttpResponse.json({ + success: true, + status_code: operation.statusCode, + data: operation.data, + }); + }), +]; diff --git a/tests/mocks/platform/sso.ts b/tests/mocks/platform/sso.ts new file mode 100644 index 00000000..e36bd7f9 --- /dev/null +++ b/tests/mocks/platform/sso.ts @@ -0,0 +1,35 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest } from "./state"; + +const idTokens = new Map(); +const accessTokens = new Map(); + +// These legacy SDK routes were not found in apper d9ae151. Keep their +// compatibility behavior centralized and explicitly avoid claiming fidelity. + +export const ssoFixtures = { + tokens(userId: string, value: { idToken?: string; accessToken?: string }) { + if (value.idToken !== undefined) idTokens.set(userId, value.idToken); + if (value.accessToken !== undefined) accessTokens.set(userId, value.accessToken); + }, +}; + +export function resetSsoState() { + idTokens.clear(); + accessTokens.clear(); +} + +function tokenHandler(kind: "id" | "access") { + return async ({ params, request }: { params: Record; request: Request }) => { + await recordRequest(kind === "id" ? "sso.getIdToken" : "sso.getAccessToken", request); + const token = (kind === "id" ? idTokens : accessTokens).get(String(params.userId)); + return token === undefined + ? HttpResponse.json({ detail: `No ${kind === "id" ? "ID" : "access"} token stored`, code: "NOT_FOUND" }, { status: 404 }) + : HttpResponse.json(token); + }; +} + +export const ssoHandlers = [ + http.get("*/api/apps/:appId/auth/sso/idtoken/:userId", tokenHandler("id")), + http.get("*/api/apps/:appId/auth/sso/accesstoken/:userId", tokenHandler("access")), +]; diff --git a/tests/mocks/platform/state.ts b/tests/mocks/platform/state.ts new file mode 100644 index 00000000..8761bb64 --- /dev/null +++ b/tests/mocks/platform/state.ts @@ -0,0 +1,174 @@ +export type PlatformRecord = Record & { id: string }; + +export interface RecordedRequest { + route: string; + method: string; + url: string; + query: Record; + headers: Record; + body?: unknown; +} + +export type MultipartEntry = + | { name: string; value: string } + | { + name: string; + file: { name: string; type: string; size: number; bytes: number[] }; + }; + +export interface MultipartBody { + type: "multipart"; + entries: MultipartEntry[]; +} + +export interface PlatformConversation extends PlatformRecord { + agent_name: string; + messages: PlatformRecord[]; +} + +export interface PlatformRegistration { + id: string; + message: string; + otpExpiresInMinutes: number; + countryCode: string | null; +} + +export interface IntegrationEndpoint { + response: unknown; +} + +export interface CustomIntegrationOperation { + data: unknown; + statusCode: number; +} + +export interface ConnectorToken { + accessToken: string; + integrationType: string; + connectionConfig?: Record | null; +} + +export interface ConnectorProxyOutcome { + success: boolean; + phase: "responded" | "not_sent" | "timed_out" | "sent_unconfirmed"; + status: number | null; + data: unknown; + dataBase64?: string | null; + contentType?: string | null; + headers?: Record; + creditsCharged?: number; +} + +export type PlatformFault = + | { kind: "integration-invalid-parameters"; packageName: string; endpointName: string } + | { kind: "custom-upstream-unavailable"; slug: string; operationId: string } + | { kind: "connector-credits-exhausted"; integrationType: string } + | { kind: "metered-connector-token-refused"; integrationType: string } + | { kind: "auth-registration-rejected"; email: string } + | { kind: "auth-reset-token-expired"; resetToken: string } + | { kind: "function-internal-error"; functionName: string } + | { kind: "function-not-found"; functionName: string } + | { kind: "function-network-unavailable"; functionName: string }; + +interface PlatformState { + entities: Map; + conversations: PlatformConversation[]; + integrationEndpoints: Map; + customIntegrations: Map>; + connectorTokens: Map; + workspaceConnectorTokens: Map; + appUserConnectorTokens: Map; + connectorProxyOutcomes: Map; + registrations: Map; + passwordResetRequestMessages: Map; + passwordResetUsers: Map; + functionResults: Map; + rawFunctions: Map; + faults: PlatformFault[]; + nextEntityId: number; + nextConversationId: number; + nextMessageId: number; + requests: RecordedRequest[]; +} + +export const state: PlatformState = { + entities: new Map(), + conversations: [], + integrationEndpoints: new Map(), + customIntegrations: new Map(), + connectorTokens: new Map(), + workspaceConnectorTokens: new Map(), + appUserConnectorTokens: new Map(), + connectorProxyOutcomes: new Map(), + registrations: new Map(), + passwordResetRequestMessages: new Map(), + passwordResetUsers: new Map(), + functionResults: new Map(), + rawFunctions: new Map(), + faults: [], + nextEntityId: 1, + nextConversationId: 1, + nextMessageId: 1, + requests: [], +}; + +export function resetPlatformState() { + state.entities.clear(); + state.conversations = []; + state.integrationEndpoints.clear(); + state.customIntegrations.clear(); + state.connectorTokens.clear(); + state.workspaceConnectorTokens.clear(); + state.appUserConnectorTokens.clear(); + state.connectorProxyOutcomes.clear(); + state.registrations.clear(); + state.passwordResetRequestMessages.clear(); + state.passwordResetUsers.clear(); + state.functionResults.clear(); + state.rawFunctions.clear(); + state.faults = []; + state.nextEntityId = 1; + state.nextConversationId = 1; + state.nextMessageId = 1; + state.requests = []; +} + +export async function recordRequest(route: string, request: Request) { + const url = new URL(request.url); + let body: unknown; + if (request.body) { + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const text = await request.clone().text(); + body = text ? JSON.parse(text) : undefined; + } else if (contentType.includes("multipart/form-data")) { + const entries: MultipartEntry[] = []; + for (const [name, value] of await request.clone().formData()) { + if (typeof value === "string") { + entries.push({ name, value }); + } else { + entries.push({ + name, + file: { + name: value.name, + type: value.type, + size: value.size, + bytes: [...new Uint8Array(await value.arrayBuffer())], + }, + }); + } + } + body = { type: "multipart", entries } satisfies MultipartBody; + } else { + body = await request.clone().text(); + } + } + state.requests.push({ + route, + method: request.method, + url: request.url, + query: Object.fromEntries(url.searchParams), + headers: Object.fromEntries(request.headers), + body, + }); +} diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts index bee93fd5..63617e93 100644 --- a/tests/mocks/server.ts +++ b/tests/mocks/server.ts @@ -1,47 +1,13 @@ +import { setupServer } from "msw/node"; +import { platformHandlers } from "./platform"; + /** - * MSW (Mock Service Worker) server for unit tests. - * - * ## How to add new handlers - * - * Call `server.use()` inside a test to register per-test handlers. - * They are automatically removed after each test by the global `afterEach` - * in `tests/setup.js` (via `server.resetHandlers()`). - * - * ```ts - * import { http, HttpResponse } from 'msw'; - * import { server } from '../mocks/server'; - * - * test('my test', async () => { - * server.use( - * http.get('https://api.base44.com/api/apps/test-app-id/entities/Todo', () => - * HttpResponse.json([{ id: '1', title: 'Test' }]) - * ) - * ); - * // ... test code - * }); - * ``` + * Transport boundary for the reusable mock Base44 platform. * - * ## Architecture - * - * ``` - * Vitest test → SDK (axios / fetch) → MSW Node server → handler → fake response - * ``` - * - * MSW intercepts requests at the Node.js http layer (`@mswjs/interceptors`) - * and also intercepts native `fetch` calls. No axios mocking or `vi.stubGlobal` - * needed. - * - * ## Modules and their base URL patterns - * - * | Module | Base path | - * |--------------|------------------------------------------------------------------| - * | entities | `/api/apps/:appId/entities/:entityName` | - * | auth | `/api/apps/:appId/entities/User/me`, `/api/apps/:appId/auth/...` | - * | functions | `/api/apps/:appId/functions/:name`, `/api/functions/:name` | - * | integrations | `/api/apps/:appId/integration-endpoints/:pkg/:endpoint` | - * | custom-int | `/api/apps/:appId/integrations/custom/:slug/:operationId` | - * | connectors | `/api/apps/:appId/external-auth/tokens/:type` | + * Handlers live in `tests/mocks/platform`, model shared domain state, and are + * installed once here. Tests arrange that state through `platform.given`, act + * only through the SDK, and may inspect the request journal for wire-level + * contracts. The global test lifecycle resets state and the journal per test. + * Module tests must not register ad-hoc handlers or author response bodies. */ -import { setupServer } from "msw/node"; - -export const server = setupServer(); +export const server = setupServer(...platformHandlers); diff --git a/tests/setup.js b/tests/setup.js index ecba0ff4..d2faeae2 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,33 +1,37 @@ -import { beforeAll, afterAll, afterEach, expect } from "vitest"; +import { beforeAll, beforeEach, afterAll, afterEach, expect } from "vitest"; import { server } from "./mocks/server.ts"; -import { verifyHttpExpectations } from "./mocks/http.ts"; +import { platform } from "./mocks/platform/index.ts"; const unexpected = []; beforeAll(() => { server.listen({ onUnhandledRequest(request, print) { unexpected.push(`${request.method} ${request.url}`); - print.error(); // Never allow a unit test to reach the real network. + print.error(); + // A custom callback otherwise defaults to passthrough after printing. + // Throwing makes MSW synthesize an intercepted 500 instead of touching + // the network; teardown still fails even when the SDK swallows it. + throw new Error(`Unhandled HTTP request: ${request.method} ${request.url}`); }, }); }); +beforeEach(() => platform.reset()); afterEach(() => { const failures = []; try { // A method swallowing network errors must still fail on unexpected traffic. try { expect(unexpected.splice(0), "Unhandled HTTP requests").toEqual([]); - } catch (error) { - failures.push(error); - } - // This also drains expectations when the unexpected-request check failed. - try { - verifyHttpExpectations(); + expect( + platform.requests.all("platform.unconfigured"), + "Requests not modeled by the mock platform", + ).toEqual([]); } catch (error) { failures.push(error); } } finally { server.resetHandlers(); + platform.reset(); } if (failures.length) throw new AggregateError(failures, "HTTP mock contract failed"); diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index 11c785e7..b6a6a6d8 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -1,5 +1,5 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { platform } from "../mocks/platform"; // Mock ReconnectingWebSocket (partysocket's `WebSocket` export) with a // controllable fake. It records the async URL provider so tests can drive @@ -599,27 +599,11 @@ describe("Actors Module — client wiring", () => { }); test("mints via POST /connection-token with app, auth, and version headers", async () => { - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, - ...{ - reqheaders: { - "x-app-id": appId, - authorization: "Bearer tok", - "base44-functions-version": "draft", - }, - }, - body: { - room: "r1", - connection_id: "c1", - }, - status: 200, - response: { - websocket_url: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", - token: "jwt.min.ted", - expires_at: "2026-01-01T00:00:00Z", - mode: "preview", - }, + platform.given.actors.available("PongGame", { + websocketUrl: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", + token: "jwt.min.ted", + expiresAt: "2026-01-01T00:00:00Z", + mode: "preview", }); const base44 = createClient({ @@ -632,18 +616,19 @@ describe("Actors Module — client wiring", () => { await expect(sockets[0].urlProvider()).resolves.toBe( "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1&token=jwt.min.ted", ); + expect(platform.requests.last("actors.mintConnectionToken")).toMatchObject({ + headers: { + "x-app-id": appId, + authorization: "Bearer tok", + "base44-functions-version": "draft", + }, + body: { room: "r1", connection_id: "c1" }, + }); base44.cleanup(); }); test("a 409 mint reply falls back to the legacy proxy URL without calling onError", async () => { - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, - status: 409, - response: { - message: "Actor must be migrated before connecting directly", - }, - }); + platform.given.actors.fault("PongGame", "legacy-conflict"); const onError = vi.fn(); const base44 = createClient({ @@ -664,16 +649,7 @@ describe("Actors Module — client wiring", () => { test("a 405 mint reply (backend without the endpoint) falls back to the proxy", async () => { // What a pre-direct backend actually answers: its actor deploy routes // match the path via `{handler_name:path}` but not the POST method. - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, - status: 405, - response: { - error_type: "HTTPException", - message: "Method Not Allowed", - detail: "Method Not Allowed", - }, - }); + platform.given.actors.fault("PongGame", "endpoint-unsupported"); const onError = vi.fn(); const base44 = createClient({ @@ -691,12 +667,7 @@ describe("Actors Module — client wiring", () => { }); test("a non-fallback mint failure reaches the client's onError as a Base44Error", async () => { - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, - status: 500, - response: { message: "mint exploded" }, - }); + platform.given.actors.fault("PongGame", "mint-failed"); const onError = vi.fn(); const base44 = createClient({ @@ -725,43 +696,25 @@ describe("Actors Module — client wiring", () => { vi.stubGlobal("document", undefined); vi.stubGlobal("localStorage", undefined); - mockHttp({ - method: "post", - url: `${serverUrl}/api/apps/${appId}/analytics/track/batch`, - status: 200, - response: {}, - inspect: async (request) => { - expect((await request.json()).events[0].event_name).toBe( - "__initialization_event__", - ); - }, - }); - const seen: unknown[] = []; - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, - times: 2, - respond: function (request) { - seen.push(request.headers.get("x-base44-anonymous-id")); - return [ - 200, - { - websocket_url: - "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", - token: "jwt.min.ted", - }, - ]; - }, + platform.given.actors.available("PongGame", { + websocketUrl: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", + token: "jwt.min.ted", + expiresAt: "2026-01-01T00:00:00Z", + mode: "preview", }); const base44 = createClient({ serverUrl, appId }); base44.actors.PongGame("r1").connect({ id: "c1" }); await sockets[0].urlProvider(); await sockets[0].urlProvider(); // a reconnect mints again + const seen = platform.requests + .all("actors.mintConnectionToken") + .map((request) => request.headers["x-base44-anonymous-id"]); expect(seen).toHaveLength(2); expect(typeof seen[0]).toBe("string"); expect(seen[0]).toBeTruthy(); expect(seen[0]).toBe(seen[1]); // stable across reconnects, not a fresh id per call + expect((platform.requests.last("analytics.trackBatch").body as any).events[0].event_name).toBe("__initialization_event__"); base44.cleanup(); }); @@ -773,18 +726,11 @@ describe("Actors Module — client wiring", () => { vi.stubGlobal("document", undefined); vi.stubGlobal("localStorage", undefined); - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/actors/PongGame/connection-token`, - ...{ - reqheaders: { authorization: "Bearer tok" }, - badheaders: ["x-base44-anonymous-id"], - }, - status: 200, - response: { - websocket_url: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", - token: "jwt.min.ted", - }, + platform.given.actors.available("PongGame", { + websocketUrl: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", + token: "jwt.min.ted", + expiresAt: "2026-01-01T00:00:00Z", + mode: "preview", }); const base44 = createClient({ serverUrl, appId, token: "tok" }); @@ -792,6 +738,9 @@ describe("Actors Module — client wiring", () => { await expect(sockets[0].urlProvider()).resolves.toContain( "token=jwt.min.ted", ); + const request = platform.requests.last("actors.mintConnectionToken"); + expect(request.headers.authorization).toBe("Bearer tok"); + expect(request.headers["x-base44-anonymous-id"]).toBeUndefined(); base44.cleanup(); }); diff --git a/tests/unit/agents.test.ts b/tests/unit/agents.test.ts index d5650b9c..c61042d6 100644 --- a/tests/unit/agents.test.ts +++ b/tests/unit/agents.test.ts @@ -1,148 +1,118 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform"; describe("Agents Module", () => { let base44: ReturnType; - const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; + const appId = "test-app-id"; beforeEach(() => { + platform.reset(); base44 = createClient({ serverUrl, appId }); }); - afterEach(() => { - base44.cleanup(); + afterEach(() => base44.cleanup()); + + test("getConversations() returns arranged conversations", async () => { + const conversations = [ + { id: "conv-1", agent_name: "support", messages: [] }, + { id: "conv-2", agent_name: "sales", messages: [] }, + ]; + platform.given.agents.conversations(conversations); + + await expect(base44.agents.getConversations()).resolves.toEqual(conversations); + expect(platform.requests.count("agents.listConversations")).toBe(1); }); - describe("getConversations", () => { - test("should fetch all conversations", async () => { - const mockConversations = [ - { id: "conv-1", agent_name: "support", messages: [] }, - { id: "conv-2", agent_name: "sales", messages: [] }, - ]; - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/agents/conversations`, - status: 200, - response: mockConversations, - }); - - const result = await base44.agents.getConversations(); - expect(result).toEqual(mockConversations); - }); + test("getConversation() returns one arranged conversation", async () => { + const conversation = { id: "conv-1", agent_name: "support", messages: [] }; + platform.given.agents.conversations([conversation]); + + await expect(base44.agents.getConversation("conv-1")).resolves.toEqual(conversation); }); - describe("getConversation", () => { - test("should fetch a specific conversation", async () => { - const mockConversation = { - id: "conv-1", - agent_name: "support", - messages: [], - }; - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/agents/conversations/conv-1`, - status: 200, - response: mockConversation, - }); - - const result = await base44.agents.getConversation("conv-1"); - expect(result).toEqual(mockConversation); + test("createConversation() persists so list and get observe it", async () => { + platform.given.agents.conversations([]); + + const created = await base44.agents.createConversation({ agent_name: "support" }); + + expect(created).toEqual({ id: "conv-1", agent_name: "support", messages: [] }); + await expect(base44.agents.getConversation(created.id)).resolves.toEqual(created); + await expect(base44.agents.getConversations()).resolves.toContainEqual(created); + expect(platform.requests.last("agents.createConversation").body).toEqual({ + agent_name: "support", }); }); - describe("createConversation", () => { - test("should create a conversation", async () => { - const created = { id: "conv-new", agent_name: "support", messages: [] }; - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/agents/conversations`, - status: 200, - body: { agent_name: "support" }, - response: created, - }); - - const result = await base44.agents.createConversation({ - agent_name: "support", - }); - expect(result).toEqual(created); + test("addMessage() posts to v2 and updates conversation state", async () => { + const conversation = { id: "conv-1", agent_name: "support", messages: [] }; + platform.given.agents.conversations([conversation]); + + const message = await base44.agents.addMessage(conversation, { + role: "user", + content: "Hi", }); - }); - describe("addMessage", () => { - test("should post to v2 endpoint", async () => { - const conversation = { - id: "conv-1", - agent_name: "support", - messages: [], - } as any; - const response = { id: "msg-1", role: "assistant", content: "Hello!" }; - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/agents/conversations/v2/conv-1/messages`, - status: 200, - body: { role: "user", content: "Hi" }, - response: response, - }); - - const result = await base44.agents.addMessage(conversation, { - role: "user", - content: "Hi", - }); - expect(result).toEqual(response); + expect(message).toEqual({ id: "msg-1", role: "user", content: "Hi" }); + await expect(base44.agents.getConversation("conv-1")).resolves.toMatchObject({ + messages: [message], + }); + expect(platform.requests.last("agents.addMessage").body).toEqual({ + role: "user", + content: "Hi", }); }); - describe("getWhatsAppConnectURL", () => { - test("should return URL without token when no auth", () => { - const url = base44.agents.getWhatsAppConnectURL("support"); - expect(url).toBe( - `${serverUrl}/api/apps/${appId}/agents/support/whatsapp`, - ); - }); + test("getWhatsAppConnectURL omits the token when unauthenticated", () => { + expect(base44.agents.getWhatsAppConnectURL("support")).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/whatsapp`, + ); + }); - test("should include token when authenticated", () => { - const authed = createClient({ serverUrl, appId, token: "test-token" }); - const url = authed.agents.getWhatsAppConnectURL("support"); - expect(url).toBe( - `${serverUrl}/api/apps/${appId}/agents/support/whatsapp?token=test-token`, - ); - authed.cleanup(); - }); + test("getWhatsAppConnectURL includes the token when authenticated", () => { + const authed = createClient({ serverUrl, appId, token: "test-token" }); + expect(authed.agents.getWhatsAppConnectURL("support")).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/whatsapp?token=test-token`, + ); + authed.cleanup(); + }); - test("should encode agent name", () => { - const url = base44.agents.getWhatsAppConnectURL("my agent"); - expect(url).toBe( - `${serverUrl}/api/apps/${appId}/agents/my%20agent/whatsapp`, - ); - }); + test("getWhatsAppConnectURL encodes the agent name", () => { + expect(base44.agents.getWhatsAppConnectURL("my agent")).toBe( + `${serverUrl}/api/apps/${appId}/agents/my%20agent/whatsapp`, + ); }); - describe("getTelegramConnectURL", () => { - test("should return URL without token when no auth", () => { - const url = base44.agents.getTelegramConnectURL("support"); - expect(url).toBe( - `${serverUrl}/api/apps/${appId}/agents/support/telegram`, - ); - }); + test("getTelegramConnectURL omits the token when unauthenticated", () => { + expect(base44.agents.getTelegramConnectURL("support")).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/telegram`, + ); + }); - test("should include token when authenticated", () => { - const authed = createClient({ serverUrl, appId, token: "test-token" }); - const url = authed.agents.getTelegramConnectURL("support"); - expect(url).toBe( - `${serverUrl}/api/apps/${appId}/agents/support/telegram?token=test-token`, - ); - authed.cleanup(); - }); + test("getTelegramConnectURL includes the token when authenticated", () => { + const authed = createClient({ serverUrl, appId, token: "test-token" }); + expect(authed.agents.getTelegramConnectURL("support")).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/telegram?token=test-token`, + ); + authed.cleanup(); + }); - test("should encode agent name", () => { - const url = base44.agents.getTelegramConnectURL("my agent"); - expect(url).toBe( - `${serverUrl}/api/apps/${appId}/agents/my%20agent/telegram`, - ); - }); + test("getTelegramConnectURL encodes the agent name", () => { + expect(base44.agents.getTelegramConnectURL("my agent")).toBe( + `${serverUrl}/api/apps/${appId}/agents/my%20agent/telegram`, + ); + }); + + test("reset() isolates conversations and request history", async () => { + platform.given.agents.conversations([ + { id: "conv-1", agent_name: "support", messages: [] }, + ]); + await base44.agents.getConversations(); + + platform.reset(); + + await expect(base44.agents.getConversations()).resolves.toEqual([]); + expect(platform.requests.count("agents.listConversations")).toBe(1); }); }); diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 8838897d..9c2b9488 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -8,8 +8,7 @@ import { import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; import { resetAnalyticsSessionContext } from "../../src/modules/analytics.ts"; import { InternalAuthModule, User } from "../../src/modules/auth.types.ts"; -import { http, HttpResponse } from "msw"; -import { server } from "../mocks/server"; +import { platform } from "../mocks/platform"; describe("Analytics Module", () => { let base44: ReturnType; @@ -23,10 +22,7 @@ describe("Analytics Module", () => { const serverUrl = "https://api.base44.com"; beforeEach(() => { - server.use( - http.post(`${serverUrl}/api/apps/${appId}/analytics/track/batch`, () => HttpResponse.json({message: "success"})), - http.get(`${serverUrl}/api/apps/${appId}/entities/User/me`, () => HttpResponse.json({id: "test-user-id"})), - ); + platform.given.auth.user({ id: "test-user-id" }); sharedState = getSharedInstance("analytics", () => ({ requestsQueue: [], isProcessing: false, @@ -228,5 +224,13 @@ describe("Analytics Module", () => { await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(1), {timeout: 2500}); await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(0), {timeout: 2500}); await vi.waitFor(() => expect(sharedState?.isProcessing).toBe(false), {timeout: 2500}); + + const batches = platform.requests + .all("analytics.trackBatch") + .map((request) => request.body as { events: { event_name: string }[] }); + expect(batches.length).toBeGreaterThan(0); + expect(batches.every((batch) => batch.events.length <= 2)).toBe(true); + expect(batches.flatMap((batch) => batch.events.map((event) => event.event_name))) + .toEqual(Array.from({ length: 6 }, (_, index) => `test-event ${index}`)); }); }); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index b3847380..ce19a11f 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -1,81 +1,47 @@ -import { mockHttp } from "../mocks/http"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { Base44Error, createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform"; describe("App module", () => { const appId = "test-app-id"; const serverUrl = "https://base44.app"; const token = "user-token-456"; - const publicSettingsPath = `/api/apps/public/prod/public-settings/by-id/${appId}`; let base44: ReturnType; beforeEach(() => { + platform.given.app.publicSettings({ id: appId, public_settings: "public_without_login" }); base44 = createClient({ serverUrl, appId, token }); }); - afterEach(() => { - base44.cleanup(); - }); + afterEach(() => base44.cleanup()); test("getPublicSettings returns the app id and its access policy", async () => { - mockHttp({ - method: "get", - url: serverUrl + publicSettingsPath, - status: 200, - response: { id: appId, public_settings: "public_without_login" }, - }); - - const settings = await base44.app.getPublicSettings(); - - expect(settings).toEqual({ + await expect(base44.app.getPublicSettings()).resolves.toEqual({ id: appId, public_settings: "public_without_login", }); }); test("getPublicSettings authenticates with the client's token, so callers never handle it", async () => { - mockHttp({ - method: "get", - url: serverUrl + publicSettingsPath, - headers: [["Authorization", `Bearer ${token}`]], - status: 200, - response: { id: appId, public_settings: "private_with_login" }, - }); - await base44.app.getPublicSettings(); + expect(platform.requests.last("app.getPublicSettings").headers.authorization).toBe(`Bearer ${token}`); }); test("getPublicSettings sends no Authorization header for an anonymous client", async () => { const anonymous = createClient({ serverUrl, appId }); - - mockHttp({ - method: "get", - url: serverUrl + publicSettingsPath, - headers: [["Authorization", (value) => value === undefined]], - status: 200, - response: { id: appId, public_settings: "public_without_login" }, - }); - await anonymous.app.getPublicSettings(); + expect(platform.requests.last("app.getPublicSettings").headers.authorization).toBeUndefined(); + anonymous.cleanup(); }); test.each([ ["auth_required", "the visitor must sign in"], ["user_not_registered", "the visitor has no access to this app"], - ])( + ] as const)( "getPublicSettings surfaces a 403 %s as a Base44Error carrying the reason", async (reason) => { - mockHttp({ - method: "get", - url: serverUrl + publicSettingsPath, - status: 403, - response: { extra_data: { app_id: appId, reason } }, - }); - - const error = await base44.app - .getPublicSettings() - .catch((rejection) => rejection); - + platform.given.app.legacyAccessDenied(appId, reason); + const error = await base44.app.getPublicSettings().catch((rejection) => rejection); expect(error).toBeInstanceOf(Base44Error); expect(error.status).toBe(403); expect(error.data.extra_data.reason).toBe(reason); diff --git a/tests/unit/auth-registration.test.ts b/tests/unit/auth-registration.test.ts index 7f5c436a..971a283f 100644 --- a/tests/unit/auth-registration.test.ts +++ b/tests/unit/auth-registration.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { createClient } from "../../src/index"; -import { mockHttp } from "../mocks/http"; +import { platform } from "../mocks/platform"; describe("Auth registration and password recovery HTTP contracts", () => { const serverUrl = "https://api.base44.com"; @@ -17,74 +17,73 @@ describe("Auth registration and password recovery HTTP contracts", () => { turnstile_token: "challenge", referral_code: "referral", }; - mockHttp({ - method: "post", - url: `${serverUrl}/api/apps/${appId}/auth/register`, - body: payload, - response: { message: "Verification required" }, + platform.given.auth.registration(payload.email, { + id: "new-user-id", + message: "Verification required", + otpExpiresInMinutes: 10, + countryCode: "US", }); expect(await client.auth.register(payload)).toEqual({ + id: "new-user-id", message: "Verification required", + otp_expires_in_minutes: 10, + country_code: "US", }); + expect(platform.requests.last("auth.register").body).toEqual(payload); }); test("registration rejection preserves the platform error response", async () => { const payload = { email: "existing@example.test", password: "test-only-password", }; - mockHttp({ - method: "post", - url: `${serverUrl}/api/apps/${appId}/auth/register`, - body: payload, - status: 400, - response: { detail: "Registration rejected" }, - }); + platform.given.faults.auth.registrationRejected(payload.email); await expect(client.auth.register(payload)).rejects.toMatchObject({ status: 400, message: "Registration rejected", }); + expect(platform.requests.last("auth.register").body).toEqual(payload); }); test("password reset request sends only the email", async () => { - mockHttp({ - method: "post", - url: `${serverUrl}/api/apps/${appId}/auth/reset-password-request`, - body: { email: "reset@example.test" }, - response: { message: "Request accepted" }, - }); + platform.given.auth.passwordResetRequest("reset@example.test"); expect( await client.auth.resetPasswordRequest("reset@example.test"), ).toEqual({ message: "Request accepted" }); + expect( + platform.requests.last("auth.resetPasswordRequest").body, + ).toEqual({ email: "reset@example.test" }); }); test("password reset maps SDK camelCase to wire snake_case", async () => { - mockHttp({ - method: "post", - url: `${serverUrl}/api/apps/${appId}/auth/reset-password`, - body: { - reset_token: "test-reset-token", - new_password: "test-new-password", - }, - response: { message: "Password reset" }, + platform.given.auth.passwordReset("test-reset-token", { + id: "reset-user-id", + email: "reset@example.test", + name: "Reset User", }); expect( await client.auth.resetPassword({ resetToken: "test-reset-token", newPassword: "test-new-password", }), - ).toEqual({ message: "Password reset" }); + ).toEqual({ + id: "reset-user-id", + email: "reset@example.test", + name: "Reset User", + }); + expect(platform.requests.last("auth.resetPassword").body).toEqual({ + reset_token: "test-reset-token", + new_password: "test-new-password", + }); }); test("invalid reset token retains the error status and message", async () => { - mockHttp({ - method: "post", - url: `${serverUrl}/api/apps/${appId}/auth/reset-password`, - body: { reset_token: "expired", new_password: "test-new-password" }, - status: 400, - response: { detail: "Reset token expired" }, - }); + platform.given.faults.auth.resetTokenExpired("expired"); await expect( client.auth.resetPassword({ resetToken: "expired", newPassword: "test-new-password", }), ).rejects.toMatchObject({ status: 400, message: "Reset token expired" }); + expect(platform.requests.last("auth.resetPassword").body).toEqual({ + reset_token: "expired", + new_password: "test-new-password", + }); }); }); diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index f58d4723..2c5e0761 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -1,7 +1,7 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { createClient as newClient } from "../../src/index.ts"; import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; +import { platform } from "../mocks/platform"; const clients = []; const createClient = (...args) => { @@ -17,6 +17,7 @@ describe("Auth Module", () => { const appBaseUrl = "https://api.base44.com"; beforeEach(() => { + platform.reset(); // Mock window.addEventListener and document for analytics module if (typeof window !== "undefined") { if (!window.addEventListener) { @@ -56,13 +57,7 @@ describe("Auth Module", () => { role: "user", }; - // Mock the API response - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 200, - response: mockUser, - }); + platform.given.auth.user(mockUser); // Call the API const result = await base44.auth.me(); @@ -73,29 +68,17 @@ describe("Auth Module", () => { expect(result.email).toBe("test@example.com"); }); - test("should handle authentication errors", async () => { - // Mock the API error response - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 401, - response: { detail: "Unauthorized" }, - }); + test("preserves authentication error status", async () => { + platform.given.faults.auth.unauthorizedMe(); // Call the API and expect an error - await expect(base44.auth.me()).rejects.toThrow(); + await expect(base44.auth.me()).rejects.toMatchObject({ status: 401 }); }); test("shares one in-flight request between concurrent callers", async () => { const mockUser = { id: "user-123", email: "test@example.com" }; - // A single interceptor: a second GET would hit disableNetConnect and throw. - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 200, - response: mockUser, - }); + platform.given.auth.user(mockUser); const [first, second] = await Promise.all([ base44.auth.me(), @@ -104,21 +87,14 @@ describe("Auth Module", () => { expect(first).toEqual(mockUser); expect(second).toEqual(mockUser); + expect(platform.requests.count("auth.me")).toBe(1); }); test("does not reuse a resolved user across separate calls", async () => { - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 200, - response: { id: "user-1" }, - }); - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 200, - response: { id: "user-2" }, - }); + platform.given.auth.meSequence([ + { user: { id: "user-1" } }, + { user: { id: "user-2" } }, + ]); const first = await base44.auth.me(); const second = await base44.auth.me(); @@ -130,37 +106,23 @@ describe("Auth Module", () => { test("does not retain a rejected request", async () => { const mockUser = { id: "user-123" }; - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 401, - response: { detail: "Unauthorized" }, - }); - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 200, - response: mockUser, - }); + platform.given.auth.meSequence([ + { unauthorized: true }, + { user: mockUser }, + ]); await expect(base44.auth.me()).rejects.toThrow(); await expect(base44.auth.me()).resolves.toEqual(mockUser); }); test("setToken() drops an in-flight request from the previous identity", async () => { - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - delayMs: 50, - status: 200, - response: { id: "anonymous" }, - }); - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 200, - response: { id: "logged-in" }, - }); + platform.given.auth.meSequence( + [ + { user: { id: "anonymous" } }, + { user: { id: "logged-in" } }, + ], + 50, + ); const beforeLogin = base44.auth.me(); base44.auth.setToken("new-access-token", false); @@ -173,23 +135,13 @@ describe("Auth Module", () => { }); test("a superseded request does not retire the current one", async () => { - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - delayMs: 50, - status: 200, - response: { id: "anonymous" }, - }); - // One interceptor for the post-login identity: if the settling anonymous - // request retires it, the third caller issues a second GET and this test - // hits disableNetConnect. - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - delayMs: 50, - status: 200, - response: { id: "logged-in" }, - }); + platform.given.auth.meSequence( + [ + { user: { id: "anonymous" } }, + { user: { id: "logged-in" } }, + ], + 50, + ); const beforeLogin = base44.auth.me(); base44.auth.setToken("new-access-token", false); @@ -202,6 +154,7 @@ describe("Auth Module", () => { expect(await afterLogin).toEqual({ id: "logged-in" }); expect(await joined).toEqual({ id: "logged-in" }); + expect(platform.requests.count("auth.me")).toBe(2); }); test("setToken() clears the analytics session context", () => { @@ -230,13 +183,11 @@ describe("Auth Module", () => { role: "user", }; - // Mock the API response - mockHttp({ - method: "put", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - body: updateData, - status: 200, - response: updatedUser, + platform.given.auth.user({ + id: "user-123", + name: "Original Name", + email: "original@example.com", + role: "user", }); // Call the API @@ -246,6 +197,7 @@ describe("Auth Module", () => { expect(result).toEqual(updatedUser); expect(result.name).toBe("Updated Name"); expect(result.email).toBe("updated@example.com"); + expect(platform.requests.last("auth.updateMe").body).toEqual(updateData); }); test("should handle validation errors", async () => { @@ -253,17 +205,15 @@ describe("Auth Module", () => { email: "invalid-email", }; - // Mock the API error response - mockHttp({ - method: "put", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - body: invalidData, - status: 400, - response: { detail: "Invalid email format" }, - }); + platform.given.auth.user({ id: "user-123", email: "valid@example.com" }); + platform.given.faults.auth.rejectedUpdate(); // Call the API and expect an error - await expect(base44.auth.updateMe(invalidData)).rejects.toThrow(); + await expect(base44.auth.updateMe(invalidData)).rejects.toMatchObject({ + status: 400, + message: "Invalid email format", + }); + expect(platform.requests.last("auth.updateMe").body).toEqual(invalidData); }); }); @@ -380,32 +330,25 @@ describe("Auth Module", () => { // Set a token first base44.auth.setToken("test-token", false); - // Mock the API response for me() call - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - headers: [["Authorization", "Bearer test-token"]], - status: 200, - response: { id: "user-123", email: "test@example.com" }, + platform.given.auth.user({ + id: "user-123", + email: "test@example.com", }); // Verify token is set by making a request await base44.auth.me(); + expect(platform.requests.last("auth.me").headers.authorization).toBe( + "Bearer test-token", + ); // Call logout base44.auth.logout(); - // Mock another me() call to verify no Authorization header is sent - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - headers: [["Authorization", (val) => !val]], - status: 401, - response: { detail: "Unauthorized" }, - }); + platform.given.faults.auth.unauthorizedMe(); // Verify no Authorization header is sent after logout (should throw 401) await expect(base44.auth.me()).rejects.toThrow(); + expect(platform.requests.last("auth.me").headers.authorization).toBeUndefined(); }); test("should remove token from localStorage in browser environment", async () => { @@ -521,17 +464,16 @@ describe("Auth Module", () => { base44.auth.setToken(token, false); - // Mock the API response for me() call - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - headers: [["Authorization", `Bearer ${token}`]], - status: 200, - response: { id: "user-123", email: "test@example.com" }, + platform.given.auth.user({ + id: "user-123", + email: "test@example.com", }); // Verify token is set by making a request await base44.auth.me(); + expect(platform.requests.last("auth.me").headers.authorization).toBe( + `Bearer ${token}`, + ); }); test("should save token to localStorage when requested", () => { @@ -586,17 +528,11 @@ describe("Auth Module", () => { test("should handle empty token gracefully", async () => { base44.auth.setToken("", false); - // Mock the API response for me() call - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - headers: [["Authorization", (val) => !val]], - status: 401, - response: { detail: "Unauthorized" }, - }); + platform.given.faults.auth.unauthorizedMe(); // Verify no Authorization header is sent (should throw 401) await expect(base44.auth.me()).rejects.toThrow(); + expect(platform.requests.last("auth.me").headers.authorization).toBeUndefined(); }); test("should handle localStorage errors gracefully", () => { @@ -645,13 +581,11 @@ describe("Auth Module", () => { }, }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/auth/login`, - body: loginData, - status: 200, - response: mockResponse, + platform.given.auth.login({ + email: loginData.email, + password: loginData.password, + accessToken: mockResponse.access_token, + user: mockResponse.user, }); // Call the API @@ -663,17 +597,13 @@ describe("Auth Module", () => { // Verify the response expect(result.access_token).toBe("test-access-token"); expect(result.user.email).toBe("test@example.com"); + expect(platform.requests.last("auth.login").body).toEqual(loginData); // Verify token was set in axios headers by making a subsequent request - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - headers: [["Authorization", "Bearer test-access-token"]], - status: 200, - response: { id: "user-123", email: "test@example.com" }, - }); - await base44.auth.me(); + expect(platform.requests.last("auth.me").headers.authorization).toBe( + "Bearer test-access-token", + ); }); test("should login with turnstile token when provided", async () => { @@ -691,13 +621,11 @@ describe("Auth Module", () => { }, }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/auth/login`, - body: loginData, - status: 200, - response: mockResponse, + platform.given.auth.login({ + email: loginData.email, + password: loginData.password, + accessToken: mockResponse.access_token, + user: mockResponse.user, }); // Call the API @@ -709,33 +637,22 @@ describe("Auth Module", () => { // Verify the response expect(result.access_token).toBe("test-access-token"); + expect(platform.requests.last("auth.login").body).toEqual(loginData); // Verify token was set in axios headers by making a subsequent request - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - headers: [["Authorization", "Bearer test-access-token"]], - status: 200, - response: { id: "user-123", email: "test@example.com" }, - }); - await base44.auth.me(); + expect(platform.requests.last("auth.me").headers.authorization).toBe( + "Bearer test-access-token", + ); }); - test("should handle authentication errors and logout", async () => { + test("preserves the platform invalid-credentials response", async () => { const loginData = { email: "test@example.com", password: "wrongpassword", }; - // Mock the API error response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/auth/login`, - body: loginData, - status: 401, - response: { detail: "Invalid credentials" }, - }); + platform.given.faults.auth.invalidCredentials(loginData.email); // Set a token first to test logout base44.auth.setToken("existing-token", false); @@ -743,7 +660,11 @@ describe("Auth Module", () => { // Call the API and expect an error await expect( base44.auth.loginViaEmailPassword(loginData.email, loginData.password), - ).rejects.toThrow(); + ).rejects.toMatchObject({ + status: 400, + message: "Invalid credentials", + }); + expect(platform.requests.last("auth.login").body).toEqual(loginData); }); test("should handle network errors", async () => { @@ -752,18 +673,13 @@ describe("Auth Module", () => { password: "password123", }; - // Mock network error - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/auth/login`, - body: loginData, - networkError: true, - }); + platform.given.faults.auth.networkUnavailableLogin(loginData.email); // Call the API and expect an error await expect( base44.auth.loginViaEmailPassword(loginData.email, loginData.password), ).rejects.toThrow(); + expect(platform.requests.last("auth.login").body).toEqual(loginData); }); }); @@ -774,13 +690,7 @@ describe("Auth Module", () => { email: "test@example.com", }; - // Mock the API response - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 200, - response: mockUser, - }); + platform.given.auth.user(mockUser); // Call the API const result = await base44.auth.isAuthenticated(); @@ -790,13 +700,7 @@ describe("Auth Module", () => { }); test("should return false when token is invalid", async () => { - // Mock the API error response - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - status: 401, - response: { detail: "Unauthorized" }, - }); + platform.given.faults.auth.unauthorizedMe(); // Call the API const result = await base44.auth.isAuthenticated(); @@ -806,12 +710,7 @@ describe("Auth Module", () => { }); test("should return false on network errors", async () => { - // Mock network error - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/me`, - networkError: true, - }); + platform.given.faults.auth.networkUnavailableMe(); // Call the API const result = await base44.auth.isAuthenticated(); diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 150ba7ac..9ec3f0da 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -1,9 +1,9 @@ -import { mockHttp } from "../mocks/http"; import { createClient as newClient, createClientFromRequest as newClientFromRequest, } from "../../src/index.ts"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { platform } from "../mocks/platform"; const clients = []; const createClient = (...args) => { @@ -16,6 +16,7 @@ const createClientFromRequest = (...args) => { clients.push(client); return client; }; +beforeEach(() => platform.reset()); afterEach(() => { for (const client of clients.splice(0)) client.cleanup(); }); @@ -354,27 +355,16 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - // Mock user entities request (should use user token) - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - headers: [["Authorization", `Bearer ${userToken}`]], - status: 200, - response: { items: [], total: 0 }, - }); - - // Mock service role entities request (should use service token) - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - headers: [["Authorization", `Bearer ${serviceToken}`]], - status: 200, - response: { items: [], total: 0 }, - }); + platform.given.entities.records("Todo", []); // Make requests await client.entities.Todo.list(); await client.asServiceRole.entities.Todo.list(); + const requests = platform.requests.all("entities.list"); + expect(requests.map((request) => request.headers.authorization)).toEqual([ + `Bearer ${userToken}`, + `Bearer ${serviceToken}`, + ]); }); test("should use service token for service role entities operations", async () => { @@ -386,14 +376,9 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - // Mock service role entities request - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/123`, - headers: [["Authorization", `Bearer ${serviceToken}`]], - status: 200, - response: { id: "123", name: "Test User" }, - }); + platform.given.entities.records("User", [ + { id: "123", name: "Test User" }, + ]); // Make request const result = await client.asServiceRole.entities.User.get("123"); @@ -401,6 +386,9 @@ describe("Service Role Authorization Headers", () => { // Verify response expect(result.id).toBe("123"); expect(result.name).toBe("Test User"); + expect(platform.requests.last("entities.get").headers.authorization).toBe( + `Bearer ${serviceToken}`, + ); }); test("should use service token for service role integrations operations", async () => { @@ -412,15 +400,7 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - // Mock service role integrations request - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, - headers: [["Authorization", `Bearer ${serviceToken}`]], - status: 200, - response: { success: true, messageId: "123" }, - }); + platform.given.integrations.emailDelivered("123"); // Make request const result = await client.asServiceRole.integrations.Core.SendEmail({ @@ -432,6 +412,9 @@ describe("Service Role Authorization Headers", () => { // Verify response expect(result.success).toBe(true); expect(result.messageId).toBe("123"); + expect(platform.requests.last("integrations.invoke").headers.authorization).toBe( + `Bearer ${serviceToken}`, + ); }); test("should use service token for service role functions operations", async () => { @@ -443,14 +426,8 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - // Mock service role functions request - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/testFunction`, - body: { param: "test" }, - headers: [["Authorization", `Bearer ${serviceToken}`]], - status: 200, - response: { result: "function executed" }, + platform.given.functions.result("testFunction", { + result: "function executed", }); // Make request @@ -460,6 +437,10 @@ describe("Service Role Authorization Headers", () => { // Verify response expect(result.data.result).toBe("function executed"); + expect(platform.requests.last("functions.invoke")).toMatchObject({ + body: { param: "test" }, + headers: { authorization: `Bearer ${serviceToken}` }, + }); }); test("should use user token for regular operations when both tokens are present", async () => { @@ -473,24 +454,10 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - // Mock regular user entities request (should use user token) - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Task`, - headers: [["Authorization", `Bearer ${userToken}`]], - status: 200, - response: { items: [{ id: "task1", title: "User Task" }], total: 1 }, - }); - - // Mock regular integrations request (should use user token) - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, - headers: [["Authorization", `Bearer ${userToken}`]], - status: 200, - response: { success: true, messageId: "email123" }, - }); + platform.given.entities.records("Task", [ + { id: "task1", title: "User Task" }, + ]); + platform.given.integrations.emailDelivered("email123"); // Make requests using regular client (not service role) const taskResult = await client.entities.Task.list(); @@ -501,9 +468,15 @@ describe("Service Role Authorization Headers", () => { }); // Verify responses - expect(taskResult.items[0].title).toBe("User Task"); + expect(taskResult[0].title).toBe("User Task"); expect(emailResult.success).toBe(true); expect(emailResult.messageId).toBe("email123"); + expect(platform.requests.last("entities.list").headers.authorization).toBe( + `Bearer ${userToken}`, + ); + expect(platform.requests.last("integrations.invoke").headers.authorization).toBe( + `Bearer ${userToken}`, + ); }); test("should work without authorization header when no tokens are provided", async () => { @@ -512,20 +485,16 @@ describe("Service Role Authorization Headers", () => { appId, }); - // Mock request without authorization header - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/PublicData`, - headers: [["Authorization", (val) => !val]], - status: 200, - response: { items: [{ id: "public1", data: "public" }], total: 1 }, - }); + platform.given.entities.records("PublicData", [ + { id: "public1", data: "public" }, + ]); // Make request const result = await client.entities.PublicData.list(); // Verify response - expect(result.items[0].data).toBe("public"); + expect(result[0].data).toBe("public"); + expect(platform.requests.last("entities.list").headers.authorization).toBeUndefined(); }); test("should propagate Base44-State header in API requests when created from request", async () => { @@ -547,22 +516,14 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - // Mock entities request and verify Base44-State header is present - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - headers: [ - ["Base44-State", clientIp], - ["Authorization", "Bearer user-token-123"], - ], - status: 200, - response: { items: [], total: 0 }, - }); + platform.given.entities.records("Todo", []); // Make request await client.entities.Todo.list(); - - // Verify all mocks were called (including header match) + expect(platform.requests.last("entities.list").headers).toMatchObject({ + authorization: "Bearer user-token-123", + "base44-state": clientIp, + }); }); test("should propagate X-Data-Env header on user-scoped API requests when created from request", async () => { @@ -582,20 +543,13 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - // The user-scoped client (not asServiceRole) must still carry the data env - // so test-mode function callbacks hit test data, not production. - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - headers: [ - ["X-Data-Env", "dev"], - ["Authorization", "Bearer user-token-123"], - ], - status: 200, - response: { items: [], total: 0 }, - }); + platform.given.entities.records("Todo", []); await client.entities.Todo.list(); + expect(platform.requests.last("entities.list").headers).toMatchObject({ + authorization: "Bearer user-token-123", + "x-data-env": "dev", + }); }); test("should not forward an X-Data-Env value outside the dev/prod set", async () => { @@ -615,18 +569,12 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - headers: [ - ["X-Data-Env", (val) => !val], - ["Authorization", "Bearer user-token-123"], - ], - status: 200, - response: { items: [], total: 0 }, - }); + platform.given.entities.records("Todo", []); await client.entities.Todo.list(); + const headers = platform.requests.last("entities.list").headers; + expect(headers.authorization).toBe("Bearer user-token-123"); + expect(headers["x-data-env"]).toBeUndefined(); }); test("should not include X-Data-Env header when not present in original request", async () => { @@ -645,18 +593,12 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - headers: [ - ["X-Data-Env", (val) => !val], - ["Authorization", "Bearer user-token-123"], - ], - status: 200, - response: { items: [], total: 0 }, - }); + platform.given.entities.records("Todo", []); await client.entities.Todo.list(); + const headers = platform.requests.last("entities.list").headers; + expect(headers.authorization).toBe("Bearer user-token-123"); + expect(headers["x-data-env"]).toBeUndefined(); }); test("should not include Base44-State header when not present in original request", async () => { @@ -675,20 +617,13 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - // Mock entities request and verify Base44-State header is NOT present - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - headers: [ - ["Base44-State", (val) => !val], - ["Authorization", "Bearer user-token-123"], - ], - status: 200, - response: { items: [], total: 0 }, - }); + platform.given.entities.records("Todo", []); // Make request await client.entities.Todo.list(); + const headers = platform.requests.last("entities.list").headers; + expect(headers.authorization).toBe("Bearer user-token-123"); + expect(headers["base44-state"]).toBeUndefined(); }); test("should propagate Base44-State header in service role API requests", async () => { @@ -710,24 +645,18 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - // Mock service role entities request and verify Base44-State header is present - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/User/123`, - headers: [ - ["Base44-State", clientIp], - ["Authorization", "Bearer service-token-123"], - ], - status: 200, - response: { id: "123", name: "Test User" }, - }); + platform.given.entities.records("User", [ + { id: "123", name: "Test User" }, + ]); // Make request using service role const result = await client.asServiceRole.entities.User.get("123"); // Verify response expect(result.id).toBe("123"); - - // Verify all mocks were called (including header match) + expect(platform.requests.last("entities.get").headers).toMatchObject({ + authorization: "Bearer service-token-123", + "base44-state": clientIp, + }); }); }); diff --git a/tests/unit/connectors-proxy.test.ts b/tests/unit/connectors-proxy.test.ts index 2a38a05e..8446ed26 100644 --- a/tests/unit/connectors-proxy.test.ts +++ b/tests/unit/connectors-proxy.test.ts @@ -1,337 +1,136 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform/index.ts"; + +const responded = { + success: true, + phase: "responded" as const, + status: 201, + data: { data: { id: "1" } }, + headers: { "x-rate-limit-remaining": "42" }, + creditsCharged: 3, +}; describe("Connectors module – metered connector proxy", () => { - const appId = "test-app-id"; - const serverUrl = "https://base44.app"; - const serviceToken = "service-token-123"; let base44: ReturnType; beforeEach(() => { - base44 = createClient({ serverUrl, appId, serviceToken }); - }); - - afterEach(() => { - base44.cleanup(); + base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id", serviceToken: "service-token-123" }); + platform.given.connectors.proxyOutcome("x", responded); }); - - const proxyResponse = { - success: true, - phase: "responded", - status_code: 201, - data: { data: { id: "1" } }, - headers: { "x-rate-limit-remaining": "42" }, - credits_charged: 3, - }; + afterEach(() => base44.cleanup()); test("posts the normalized request to the shared-connector proxy route", async () => { - let received: any; - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/x/call`, - body: (body) => { - received = body; - return true; - }, - status: 200, - response: proxyResponse, - }); - - await base44.asServiceRole.connectors.callApi("x", { + await base44.asServiceRole.connectors.callApi("x", { method: "POST", path: "/2/tweets", body: { text: "hi" } }); + expect(platform.requests.last("connectors.callApi")).toMatchObject({ method: "POST", - path: "/2/tweets", - body: { text: "hi" }, + headers: { authorization: "Bearer service-token-123" }, + body: { method: "POST", path: "/2/tweets", body: { text: "hi" }, query: {}, headers: {} }, }); - - expect(received.method).toBe("POST"); - expect(received.path).toBe("/2/tweets"); - expect(received.body).toEqual({ text: "hi" }); - // Absent fields are sent as empties rather than omitted, so the server - // never has to distinguish "missing" from "empty". - expect(received.query).toEqual({}); - expect(received.headers).toEqual({}); }); test("percent-encodes the integration type so it stays on the connectors route", async () => { - // The route carries the service-role token, so a runtime-built identifier - // containing slashes must select a (nonexistent) connector, not another route. - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/connectors/${encodeURIComponent("../evil/route")}/call`, - status: 200, - response: proxyResponse, - }); - - const res = await base44.asServiceRole.connectors.callApi( - "../evil/route" as any, - { path: "/x" }, - ); - - expect(res.success).toBe(true); + platform.given.connectors.proxyOutcome("../evil/route", responded); + const result = await base44.asServiceRole.connectors.callApi("../evil/route" as any, { path: "/x" }); + expect(result.success).toBe(true); + expect(platform.requests.last("connectors.callApi").url).toContain("connectors/..%2Fevil%2Froute/call"); }); test("forwards a named host, and omits it entirely when unset", async () => { - // The payload is built field by field, so anything not explicitly forwarded - // is silently dropped — which is what happened to `host` before this. - const bodies: any[] = []; - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/googlemaps/call`, - body: (body) => { - bodies.push(body); - return true; - }, - times: 3, - status: 200, - response: proxyResponse, - }); - - await base44.asServiceRole.connectors.callApi("googlemaps", { - host: "places", - path: "/v1/places:searchText", - }); - await base44.asServiceRole.connectors.callApi("googlemaps", { - path: "/maps/api/geocode/json", - }); - await base44.asServiceRole.connectors.callApi("googlemaps", { - host: null as any, - path: "/maps/api/geocode/json", - }); - + platform.given.connectors.proxyOutcome("googlemaps", responded); + await base44.asServiceRole.connectors.callApi("googlemaps", { host: "places", path: "/v1/places:searchText" }); + await base44.asServiceRole.connectors.callApi("googlemaps", { path: "/maps/api/geocode/json" }); + await base44.asServiceRole.connectors.callApi("googlemaps", { host: null as any, path: "/maps/api/geocode/json" }); + const bodies = platform.requests.all("connectors.callApi").map((request) => request.body as Record); expect(bodies[0].host).toBe("places"); - // Absent rather than null, so the proxy picks the connector's default host. expect("host" in bodies[1]).toBe(false); - // Untyped callers write `host: x ?? null`; null must mean unset, not a host. expect("host" in bodies[2]).toBe(false); }); test("maps a binary response to dataBase64 + contentType", async () => { - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/googlemaps/call`, - status: 200, - response: { - success: true, - phase: "responded", - status_code: 200, - data: null, - data_base64: "iVBORw0KGgo=", - content_type: "image/png", - headers: {}, - credits_charged: 1, - }, - }); - - const res = await base44.asServiceRole.connectors.callApi("googlemaps", { - path: "/maps/api/staticmap", + platform.given.connectors.proxyOutcome("googlemaps", { + success: true, phase: "responded", status: 200, data: null, + dataBase64: "iVBORw0KGgo=", contentType: "image/png", creditsCharged: 1, }); - - expect(res.dataBase64).toBe("iVBORw0KGgo="); - expect(res.contentType).toBe("image/png"); - expect(res.data).toBeNull(); + const result = await base44.asServiceRole.connectors.callApi("googlemaps", { path: "/maps/api/staticmap" }); + expect(result).toMatchObject({ dataBase64: "iVBORw0KGgo=", contentType: "image/png", data: null }); }); test("leaves dataBase64 and contentType null for a JSON response", async () => { - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/x/call`, - status: 200, - response: proxyResponse, - }); - - const res = await base44.asServiceRole.connectors.callApi("x", { - path: "/2/users/me", - }); - - expect(res.dataBase64).toBeNull(); - expect(res.contentType).toBeNull(); + const result = await base44.asServiceRole.connectors.callApi("x", { path: "/2/users/me" }); + expect(result.dataBase64).toBeNull(); + expect(result.contentType).toBeNull(); }); test("defaults the method to GET", async () => { - let received: any; - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/x/call`, - body: (body) => { - received = body; - return true; - }, - status: 200, - response: proxyResponse, - }); - await base44.asServiceRole.connectors.callApi("x", { path: "/2/users/me" }); - - expect(received.method).toBe("GET"); + expect(platform.requests.last("connectors.callApi").body).toMatchObject({ method: "GET" }); }); test("forwards query parameters so the priced call matches the sent call", async () => { - // The server prices the merged query; dropping it client-side would make the - // quoted price and the real request disagree. - let received: any; - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/x/call`, - body: (body) => { - received = body; - return true; - }, - status: 200, - response: proxyResponse, - }); - - await base44.asServiceRole.connectors.callApi("x", { - path: "/2/tweets/search/recent", - query: { query: "base44", max_results: 10 }, - }); - - expect(received.query).toEqual({ query: "base44", max_results: 10 }); + const query = { query: "base44", max_results: 10 }; + await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets/search/recent", query }); + expect(platform.requests.last("connectors.callApi").body).toMatchObject({ query }); }); test("maps the proxy envelope to camelCase", async () => { - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/x/call`, - status: 200, - response: proxyResponse, - }); - - const res = await base44.asServiceRole.connectors.callApi("x", { - path: "/2/tweets", + const result = await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }); + expect(result).toEqual({ + success: true, phase: "responded", status: 201, data: { data: { id: "1" } }, + dataBase64: null, contentType: null, headers: { "x-rate-limit-remaining": "42" }, creditsCharged: 3, }); - - expect(res.success).toBe(true); - expect(res.phase).toBe("responded"); - expect(res.status).toBe(201); - expect(res.data).toEqual({ data: { id: "1" } }); - expect(res.headers).toEqual({ "x-rate-limit-remaining": "42" }); - expect(res.creditsCharged).toBe(3); }); test("returns an upstream error instead of throwing", async () => { - // A provider 4xx is a normal outcome of a call Base44 completed (and billed), - // so it must be inspectable rather than an exception. - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/x/call`, - status: 200, - response: { - success: false, - phase: "responded", - status_code: 400, - data: { title: "Invalid Request" }, - headers: {}, - credits_charged: 3, - }, + platform.given.connectors.proxyOutcome("x", { + success: false, phase: "responded", status: 400, + data: { title: "Invalid Request" }, creditsCharged: 3, }); - - const res = await base44.asServiceRole.connectors.callApi("x", { - method: "POST", - path: "/2/tweets", - body: {}, + const result = await base44.asServiceRole.connectors.callApi("x", { method: "POST", path: "/2/tweets", body: {} }); + expect(result).toMatchObject({ + success: false, phase: "responded", status: 400, + data: { title: "Invalid Request" }, creditsCharged: 3, }); - - expect(res.success).toBe(false); - expect(res.phase).toBe("responded"); - expect(res.status).toBe(400); - expect(res.data).toEqual({ title: "Invalid Request" }); - // Still charged: the vendor counted the request. - expect(res.creditsCharged).toBe(3); }); test("rejects when Base44 itself refuses the call", async () => { - // Credits exhausted is a Base44-side failure, not an upstream outcome. - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/x/call`, - status: 402, - response: { - message: "You have reached the limit of integrations for this month", - extra_data: { reason: "integration_credits_limit_reached" }, - }, + platform.given.faults.connectors.creditsExhausted("x"); + await expect(base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" })).rejects.toMatchObject({ status: 402 }); + await expect(base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" })).resolves.toMatchObject({ + success: true, + status: 201, }); - - await expect( - base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }), - ).rejects.toMatchObject({ status: 402 }); }); test("a metered connector's token request surfaces the actionable refusal", async () => { - // The backend's 403 detail names the proxy, which is what lets generated - // code (and the model that wrote it) correct itself. - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/external-auth/tokens/x`, - status: 403, - response: { - detail: - "Connector 'x' is metered — raw access tokens are not available for it. " + - `Call POST /api/apps/${appId}/connectors/x/call instead.`, - }, - responseHeaders: { - "X-Base44-Connector-Error": "metered_connector_requires_proxy", - }, - }); - - await expect( - base44.asServiceRole.connectors.getConnection("x"), - ).rejects.toMatchObject({ + platform.given.faults.connectors.meteredTokenRequiresProxy("x"); + await expect(base44.asServiceRole.connectors.getConnection("x")).rejects.toMatchObject({ status: 403, code: "metered_connector_requires_proxy", message: expect.stringContaining("/connectors/x/call"), }); }); - test.each(["post", "TRACE"])( - "rejects unsupported request method %s before sending", - async (method) => { - await expect( - base44.asServiceRole.connectors.callApi("x", { - method: method as any, - path: "/2/tweets", - }), - ).rejects.toThrow( - "Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD", - ); - }, - ); + test.each(["post", "TRACE"])("rejects unsupported request method %s before sending", async (method) => { + await expect(base44.asServiceRole.connectors.callApi("x", { method: method as any, path: "/2/tweets" })).rejects.toThrow( + "Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD", + ); + expect(platform.requests.count("connectors.callApi")).toBe(0); + }); test.each(["not_sent", "timed_out", "sent_unconfirmed"] as const)( "maps proxy phase %s when no upstream response is available", async (phase) => { - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/connectors/x/call`, - status: 200, - response: { - success: false, - phase, - status_code: null, - data: { error: "request outcome unknown" }, - headers: {}, - credits_charged: phase === "not_sent" ? 0 : 3, - }, + platform.given.connectors.proxyOutcome("x", { + success: false, phase, status: null, data: { error: "request outcome unknown" }, + creditsCharged: phase === "not_sent" ? 0 : 3, }); - - const res = await base44.asServiceRole.connectors.callApi("x", { - path: "/2/tweets", - }); - - expect(res.phase).toBe(phase); - expect(res.status).toBeNull(); - expect(res.success).toBe(false); + const result = await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }); + expect(result).toMatchObject({ phase, status: null, success: false }); }, ); - test.each([ - ["", "/2/tweets"], - ["x", ""], - ])("rejects a missing identifier or path (%s, %s)", async (type, path) => { - await expect( - base44.asServiceRole.connectors.callApi(type, { path }), - ).rejects.toThrow(/required and must be a string/); + test.each([["", "/2/tweets"], ["x", ""]])("rejects a missing identifier or path (%s, %s)", async (type, path) => { + await expect(base44.asServiceRole.connectors.callApi(type, { path })).rejects.toThrow(/required and must be a string/); }); }); diff --git a/tests/unit/connectors.test.ts b/tests/unit/connectors.test.ts index 23ec250c..6f923a82 100644 --- a/tests/unit/connectors.test.ts +++ b/tests/unit/connectors.test.ts @@ -1,279 +1,97 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { http, HttpResponse } from "msw"; -import { server } from "../mocks/server"; import { createClient } from "../../src/index.ts"; -import type { AppUserConnectorConnectionResponse } from "../../src/modules/connectors.types.ts"; +import { platform } from "../mocks/platform/index.ts"; describe("Connectors module – getConnection", () => { - const appId = "test-app-id"; - const serverUrl = "https://base44.app"; - const serviceToken = "service-token-123"; let base44: ReturnType; - const tokensBase = `${serverUrl}/api/apps/${appId}/external-auth/tokens`; - beforeEach(() => { - base44 = createClient({ serverUrl, appId, serviceToken }); - }); - - afterEach(() => { - base44.cleanup(); + base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id", serviceToken: "service-token-123" }); }); + afterEach(() => base44.cleanup()); - test("extracts accessToken and connectionConfig from API response", async () => { - server.use( - http.get(`${tokensBase}/jira`, () => - HttpResponse.json({ - access_token: "oauth-token-abc123", - integration_type: "jira", - connection_config: { subdomain: "my-company" }, - }), - ), - ); - - const connection = - await base44.asServiceRole.connectors.getConnection("jira"); - - expect(connection).toBeDefined(); - expect(connection.accessToken).toBe("oauth-token-abc123"); - expect(connection.connectionConfig).toEqual({ subdomain: "my-company" }); + test("extracts accessToken and connectionConfig", async () => { + platform.given.connectors.connection("jira", "oauth-token-abc123", { subdomain: "my-company" }); + const connection = await base44.asServiceRole.connectors.getConnection("jira"); + expect(connection).toEqual({ accessToken: "oauth-token-abc123", connectionConfig: { subdomain: "my-company" } }); + expect(platform.requests.last("connectors.getConnection")).toMatchObject({ + method: "GET", + headers: { authorization: "Bearer service-token-123" }, + }); }); - test("returns connectionConfig as null when API omits connection_config", async () => { - server.use( - http.get(`${tokensBase}/slack`, () => - HttpResponse.json({ - access_token: "token-only", - integration_type: "slack", - }), - ), - ); - - const connection = - await base44.asServiceRole.connectors.getConnection("slack"); - - expect(connection.accessToken).toBe("token-only"); - expect(connection.connectionConfig).toBeNull(); + test.each([ + ["slack", undefined], + ["github", null], + ])("returns null config when backend config for %s is %s", async (type, config) => { + platform.given.connectors.connection(type, "token-only", config); + await expect(base44.asServiceRole.connectors.getConnection(type)).resolves.toEqual({ + accessToken: "token-only", connectionConfig: null, + }); }); - test("returns connectionConfig as null when API sends null connection_config", async () => { - server.use( - http.get(`${tokensBase}/github`, () => - HttpResponse.json({ - access_token: "token-only", - integration_type: "github", - connection_config: null, - }), - ), + test.each(["", null])("rejects invalid integration type %s", async (type) => { + await expect(base44.asServiceRole.connectors.getConnection(type as unknown as string)).rejects.toThrow( + "Integration type is required and must be a string", ); - - const connection = - await base44.asServiceRole.connectors.getConnection("github"); - - expect(connection.accessToken).toBe("token-only"); - expect(connection.connectionConfig).toBeNull(); - }); - - test("throws when integrationType is empty string", async () => { - await expect( - base44.asServiceRole.connectors.getConnection(""), - ).rejects.toThrow("Integration type is required and must be a string"); - }); - - test("throws when integrationType is not a string", async () => { - await expect( - base44.asServiceRole.connectors.getConnection(null as unknown as string), - ).rejects.toThrow("Integration type is required and must be a string"); + expect(platform.requests.count("connectors.getConnection")).toBe(0); }); }); describe("Connectors module – getWorkspaceConnection", () => { - const appId = "test-app-id"; - const serverUrl = "https://base44.app"; - const serviceToken = "service-token-123"; let base44: ReturnType; - beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - serviceToken, - }); + base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id", serviceToken: "service-token-123" }); }); + afterEach(() => base44.cleanup()); - afterEach(() => { - base44.cleanup(); - }); - - test("extracts accessToken and connectionConfig from connectors endpoint", async () => { - const apiResponse = { - access_token: "builder-oauth-token-xyz789", - integration_type: "snowflake", - connection_config: { subdomain: "xy12345.us-east-1" }, - }; - - mockHttp({ - method: "get", - url: - serverUrl + - `/api/apps/${appId}/external-auth/tokens/connectors/connector-abc`, - status: 200, - response: apiResponse, - }); - - const connection = - await base44.asServiceRole.connectors.getWorkspaceConnection( - "connector-abc", - ); - - expect(connection.accessToken).toBe("builder-oauth-token-xyz789"); - expect(connection.connectionConfig).toEqual({ - subdomain: "xy12345.us-east-1", + test("extracts accessToken and connectionConfig", async () => { + platform.given.connectors.workspaceConnection("connector-abc", "snowflake", "builder-oauth-token-xyz789", { subdomain: "xy12345.us-east-1" }); + await expect(base44.asServiceRole.connectors.getWorkspaceConnection("connector-abc")).resolves.toEqual({ + accessToken: "builder-oauth-token-xyz789", connectionConfig: { subdomain: "xy12345.us-east-1" }, }); }); - test("returns connectionConfig as null when API omits connection_config", async () => { - const apiResponse = { - access_token: "token-only", - integration_type: "databricks", - }; - - mockHttp({ - method: "get", - url: - serverUrl + `/api/apps/${appId}/external-auth/tokens/connectors/conn-2`, - status: 200, - response: apiResponse, + test("returns null when connection_config is omitted", async () => { + platform.given.connectors.workspaceConnection("conn-2", "databricks", "token-only"); + await expect(base44.asServiceRole.connectors.getWorkspaceConnection("conn-2")).resolves.toEqual({ + accessToken: "token-only", connectionConfig: null, }); - - const connection = - await base44.asServiceRole.connectors.getWorkspaceConnection("conn-2"); - - expect(connection.accessToken).toBe("token-only"); - expect(connection.connectionConfig).toBeNull(); - }); - - test("throws when connectorId is empty string", async () => { - await expect( - base44.asServiceRole.connectors.getWorkspaceConnection(""), - ).rejects.toThrow("Connector ID is required and must be a string"); }); - test("throws when connectorId is not a string", async () => { - await expect( - base44.asServiceRole.connectors.getWorkspaceConnection( - null as unknown as string, - ), - ).rejects.toThrow("Connector ID is required and must be a string"); + test.each(["", null])("rejects invalid connector ID %s", async (id) => { + await expect(base44.asServiceRole.connectors.getWorkspaceConnection(id as unknown as string)).rejects.toThrow( + "Connector ID is required and must be a string", + ); }); }); describe("Connectors module – getCurrentAppUserConnection", () => { - const appId = "test-app-id"; - const serverUrl = "https://base44.app"; - const serviceToken = "service-token-123"; let base44: ReturnType; - beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - serviceToken, - }); + base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id", serviceToken: "service-token-123" }); }); + afterEach(() => base44.cleanup()); - afterEach(() => { - base44.cleanup(); - }); - - test("extracts accessToken and connectionConfig from API response", async () => { - const apiResponse = { - access_token: "user-oauth-token-abc123", - integration_type: "jira", - connection_config: { subdomain: "my-company" }, - }; - - mockHttp({ - method: "get", - url: - serverUrl + - `/api/apps/${appId}/app-user-auth/connectors/connector-1/token`, - status: 200, - response: apiResponse, - }); - - const connection: AppUserConnectorConnectionResponse = - await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-1", - ); - - expect(connection).toBeDefined(); - expect(connection.accessToken).toBe("user-oauth-token-abc123"); - expect(connection.connectionConfig).toEqual({ - subdomain: "my-company", + test("extracts accessToken and connectionConfig", async () => { + platform.given.connectors.appUserConnection("connector-1", "jira", "user-oauth-token-abc123", { subdomain: "my-company" }); + await expect(base44.asServiceRole.connectors.getCurrentAppUserConnection("connector-1")).resolves.toEqual({ + accessToken: "user-oauth-token-abc123", connectionConfig: { subdomain: "my-company" }, }); }); - test("returns connectionConfig as null when API omits connection_config", async () => { - const apiResponse = { - access_token: "user-token-only", - integration_type: "slack", - }; - - mockHttp({ - method: "get", - url: - serverUrl + - `/api/apps/${appId}/app-user-auth/connectors/connector-2/token`, - status: 200, - response: apiResponse, - }); - - const connection: AppUserConnectorConnectionResponse = - await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-2", - ); - - expect(connection.accessToken).toBe("user-token-only"); - expect(connection.connectionConfig).toBeNull(); - }); - - test("returns connectionConfig as null when API sends null connection_config", async () => { - const apiResponse = { - access_token: "user-token-only", - integration_type: "github", - connection_config: null, - }; - - mockHttp({ - method: "get", - url: - serverUrl + - `/api/apps/${appId}/app-user-auth/connectors/connector-3/token`, - status: 200, - response: apiResponse, + test.each([ + ["connector-2", "slack", undefined], + ["connector-3", "github", null], + ])("returns null config for %s", async (id, type, config) => { + platform.given.connectors.appUserConnection(id, type, "user-token-only", config); + await expect(base44.asServiceRole.connectors.getCurrentAppUserConnection(id)).resolves.toEqual({ + accessToken: "user-token-only", connectionConfig: null, }); - - const connection: AppUserConnectorConnectionResponse = - await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-3", - ); - - expect(connection.accessToken).toBe("user-token-only"); - expect(connection.connectionConfig).toBeNull(); }); - test("throws when connectorId is empty string", async () => { - await expect( - base44.asServiceRole.connectors.getCurrentAppUserConnection(""), - ).rejects.toThrow("Connector ID is required and must be a string"); - }); - - test("throws when connectorId is not a string", async () => { - await expect( - base44.asServiceRole.connectors.getCurrentAppUserConnection( - null as unknown as string, - ), - ).rejects.toThrow("Connector ID is required and must be a string"); + test.each(["", null])("rejects invalid connector ID %s", async (id) => { + await expect(base44.asServiceRole.connectors.getCurrentAppUserConnection(id as unknown as string)).rejects.toThrow( + "Connector ID is required and must be a string", + ); }); }); diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts index dfa3035f..ab0dc1d3 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -1,423 +1,128 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform/index.ts"; describe("Custom Integrations Module", () => { let base44: ReturnType; - const appId = "test-app-id"; - const serverUrl = "https://base44.app"; - beforeEach(() => { - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - }); - }); - - afterEach(() => { - base44.cleanup(); + base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id" }); }); + afterEach(() => base44.cleanup()); - test("custom.call() should convert camelCase params to snake_case for backend", async () => { - const slug = "github"; + test("converts camelCase params to snake_case for the backend", async () => { const operationId = "get:/repos/{owner}/{repo}/issues"; - - // SDK call uses camelCase (JS convention) - const sdkParams = { + platform.given.customIntegrations.operation("github", operationId, { + issues: [{ id: 1, title: "Test Issue" }], + }); + const result = await base44.integrations.custom.call("github", operationId, { payload: { title: "Test Issue" }, pathParams: { owner: "testuser", repo: "testrepo" }, queryParams: { state: "open" }, - }; - - // Backend expects snake_case (Python convention) - const expectedBody = { + }); + expect(result).toMatchObject({ success: true, status_code: 200 }); + expect(result.data.issues).toHaveLength(1); + expect(platform.requests.last("customIntegrations.call").body).toEqual({ payload: { title: "Test Issue" }, path_params: { owner: "testuser", repo: "testrepo" }, query_params: { state: "open" }, - }; - - const mockResponse = { - success: true, - status_code: 200, - data: { issues: [{ id: 1, title: "Test Issue" }] }, - }; - - const encodedOperationId = operationId - .replace(/{/g, "%7B") - .replace(/}/g, "%7D"); - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, - body: expectedBody, - status: 200, - response: mockResponse, }); - - // SDK call uses camelCase - const result = await base44.integrations.custom.call( - slug, - operationId, - sdkParams, - ); - - // Verify the response - expect(result.success).toBe(true); - expect(result.status_code).toBe(200); - expect(result.data.issues).toHaveLength(1); }); - test("custom.call() should work with empty params", async () => { - const slug = "github"; - const operationId = "getAuthenticatedUser"; - - const mockResponse = { - success: true, - status_code: 200, - data: { login: "testuser", id: 123 }, - }; - - // Mock the API response - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, - body: {}, - status: 200, - response: mockResponse, - }); - - // Call without params - const result = await base44.integrations.custom.call(slug, operationId); - - // Verify the response - expect(result.success).toBe(true); + test("works with empty params", async () => { + platform.given.customIntegrations.operation("github", "getAuthenticatedUser", { login: "testuser", id: 123 }); + const result = await base44.integrations.custom.call("github", "getAuthenticatedUser"); expect(result.data.login).toBe("testuser"); + expect(platform.requests.last("customIntegrations.call").body).toEqual({}); }); - test("custom.call() should handle 404 error for non-existent integration", async () => { - const slug = "nonexistent"; - const operationId = "someEndpoint"; - - // Mock a 404 error response - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, - body: {}, - status: 404, - response: { - detail: `Custom integration '${slug}' not found in workspace`, - }, - }); - - // Call the API and expect an error - await expect( - base44.integrations.custom.call(slug, operationId), - ).rejects.toMatchObject({ - status: 404, - name: "Base44Error", + test("maps missing integration to a 404 Base44Error", async () => { + await expect(base44.integrations.custom.call("nonexistent", "someEndpoint")).rejects.toMatchObject({ + status: 404, name: "Base44Error", message: "Custom integration 'nonexistent' not found in workspace", }); }); - test("custom.call() should handle 404 error for non-existent operation", async () => { - const slug = "github"; - const operationId = "nonExistentOperation"; - - // Mock a 404 error response - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, - body: {}, - status: 404, - response: { - detail: `Operation '${operationId}' not found in integration '${slug}'`, - }, - }); - - // Call the API and expect an error - await expect( - base44.integrations.custom.call(slug, operationId), - ).rejects.toMatchObject({ - status: 404, - name: "Base44Error", + test("maps missing operation to a 404 Base44Error", async () => { + platform.given.customIntegrations.operation("github", "existingOperation", {}); + await expect(base44.integrations.custom.call("github", "nonExistentOperation")).rejects.toMatchObject({ + status: 404, name: "Base44Error", + message: "Operation 'nonExistentOperation' not found in integration 'github'", }); }); - test("custom.call() should handle 502 error from external API", async () => { - const slug = "github"; + test("returns the current backend envelope for an upstream-unavailable fault", async () => { const operationId = "get:/repos/{owner}/{repo}/issues"; - - // Mock a 502 error response (external API failure) - curly braces in operationId must be URL-encoded - const encodedOperationId = operationId - .replace(/{/g, "%7B") - .replace(/}/g, "%7D"); - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, - body: {}, - status: 502, - response: { - detail: "Failed to connect to external API: Connection refused", - }, + platform.given.customIntegrations.operation("github", operationId, { issues: [] }); + platform.given.faults.customIntegrations.upstreamUnavailable("github", operationId); + await expect(base44.integrations.custom.call("github", operationId)).resolves.toEqual({ + success: false, + status_code: 502, + data: { detail: "Failed to connect to external API: Connection refused" }, }); - - // Call the API and expect an error - await expect( - base44.integrations.custom.call(slug, operationId), - ).rejects.toMatchObject({ - status: 502, - name: "Base44Error", + await expect(base44.integrations.custom.call("github", operationId)).resolves.toEqual({ + success: true, + status_code: 200, + data: { issues: [] }, }); }); - test("custom.call() should throw error when slug is missing", async () => { - // @ts-expect-error Testing invalid input - await expect(base44.integrations.custom.call()).rejects.toThrow( - "Integration slug is required and cannot be empty", - ); - }); - - test("custom.call() should throw error when operationId is missing", async () => { - // @ts-expect-error Testing invalid input - await expect(base44.integrations.custom.call("github")).rejects.toThrow( - "Operation ID is required and cannot be empty", - ); - }); - - test("custom.call() should throw error when slug is empty string", async () => { - await expect( - base44.integrations.custom.call("", "get:/repos/{owner}/{repo}/issues"), - ).rejects.toThrow("Integration slug is required and cannot be empty"); - }); - - test("custom.call() should throw error when slug is whitespace only", async () => { + test.each([ + [undefined, undefined, "Integration slug is required and cannot be empty"], + ["github", undefined, "Operation ID is required and cannot be empty"], + ["", "get", "Integration slug is required and cannot be empty"], + [" ", "get", "Integration slug is required and cannot be empty"], + ["github", "", "Operation ID is required and cannot be empty"], + ["github", " \t\n ", "Operation ID is required and cannot be empty"], + ])("validates slug and operation ID (%s, %s)", async (slug, operationId, message) => { await expect( - base44.integrations.custom.call( - " ", - "get:/repos/{owner}/{repo}/issues", - ), - ).rejects.toThrow("Integration slug is required and cannot be empty"); - }); - - test("custom.call() should throw error when operationId is empty string", async () => { - await expect(base44.integrations.custom.call("github", "")).rejects.toThrow( - "Operation ID is required and cannot be empty", - ); - }); - - test("custom.call() should throw error when operationId is whitespace only", async () => { - await expect( - base44.integrations.custom.call("github", " \t\n "), - ).rejects.toThrow("Operation ID is required and cannot be empty"); + // @ts-expect-error Deliberately exercising invalid runtime input. + base44.integrations.custom.call(slug, operationId), + ).rejects.toThrow(message); + expect(platform.requests.count("customIntegrations.call")).toBe(0); }); - test("custom.call() should handle large payloads", async () => { - const slug = "myapi"; - const operationId = "bulkCreate"; - - // Create a large payload with many items - const largeArray = Array.from({ length: 1000 }, (_, i) => ({ - id: i, - name: `Item ${i}`, - description: "A".repeat(100), - metadata: { key: `value_${i}` }, + test("handles large payloads without dropping data", async () => { + const items = Array.from({ length: 1000 }, (_, id) => ({ + id, name: `Item ${id}`, description: "A".repeat(100), metadata: { key: `value_${id}` }, })); - - const sdkParams = { - payload: { items: largeArray }, - }; - - const mockResponse = { - success: true, - status_code: 200, - data: { created: 1000 }, - }; - - // Mock the API response - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, - body: sdkParams, - status: 200, - response: mockResponse, - }); - - // Call the API with large payload - const result = await base44.integrations.custom.call( - slug, - operationId, - sdkParams, - ); - - // Verify the response - expect(result.success).toBe(true); + platform.given.customIntegrations.operation("myapi", "bulkCreate", { created: 1000 }); + const result = await base44.integrations.custom.call("myapi", "bulkCreate", { payload: { items } }); expect(result.data.created).toBe(1000); + expect(platform.requests.last("customIntegrations.call").body).toEqual({ payload: { items } }); }); - test("custom.call() should include custom headers in request", async () => { - const slug = "myapi"; - const operationId = "getData"; - const sdkParams = { - headers: { "X-Custom-Header": "custom-value" }, - }; - - const mockResponse = { - success: true, - status_code: 200, - data: { result: "ok" }, - }; - - // Mock the API response - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, - body: sdkParams, - status: 200, - response: mockResponse, - }); - - // Call the API - const result = await base44.integrations.custom.call( - slug, - operationId, - sdkParams, - ); - - // Verify the response - expect(result.success).toBe(true); + test("includes custom headers in the backend request body", async () => { + const headers = { "X-Custom-Header": "custom-value" }; + platform.given.customIntegrations.operation("myapi", "getData", { result: "ok" }); + await base44.integrations.custom.call("myapi", "getData", { headers }); + expect(platform.requests.last("customIntegrations.call").body).toEqual({ headers }); }); - test("custom.call() should pass through multiple headers", async () => { - const slug = "myapi"; - const operationId = "secureEndpoint"; - const sdkParams = { - headers: { - "X-API-Key": "secret-key-123", - "X-Request-ID": "req-456", - "Accept-Language": "en-US", - "X-Custom-Auth": "Bearer token123", - }, + test("passes through multiple headers", async () => { + const headers = { + "X-API-Key": "secret-key-123", "X-Request-ID": "req-456", + "Accept-Language": "en-US", "X-Custom-Auth": "Bearer token123", }; - - const mockResponse = { - success: true, - status_code: 200, - data: { authenticated: true }, - }; - - // Mock the API response - verify all headers are passed in the body - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, - body: sdkParams, - status: 200, - response: mockResponse, - }); - - // Call the API - const result = await base44.integrations.custom.call( - slug, - operationId, - sdkParams, - ); - - // Verify the response - expect(result.success).toBe(true); + platform.given.customIntegrations.operation("myapi", "secureEndpoint", { authenticated: true }); + const result = await base44.integrations.custom.call("myapi", "secureEndpoint", { headers }); expect(result.data.authenticated).toBe(true); + expect(platform.requests.last("customIntegrations.call").body).toEqual({ headers }); }); - test("custom.call() should only include defined params in body", async () => { - const slug = "github"; + test("only includes defined params in body", async () => { const operationId = "get:/users/{username}"; - - // SDK call with only pathParams - const sdkParams = { - pathParams: { username: "octocat" }, - }; - - // Expected body should only have path_params, not empty payload/query_params/headers - const expectedBody = { + platform.given.customIntegrations.operation("github", operationId, { login: "octocat" }); + await base44.integrations.custom.call("github", operationId, { pathParams: { username: "octocat" } }); + expect(platform.requests.last("customIntegrations.call").body).toEqual({ path_params: { username: "octocat" }, - }; - - const mockResponse = { - success: true, - status_code: 200, - data: { login: "octocat" }, - }; - - const encodedOperationId = operationId - .replace(/{/g, "%7B") - .replace(/}/g, "%7D"); - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integrations/custom/${slug}/${encodedOperationId}`, - body: expectedBody, - status: 200, - response: mockResponse, }); - - const result = await base44.integrations.custom.call( - slug, - operationId, - sdkParams, - ); - - expect(result.success).toBe(true); }); - test("custom property should not interfere with other integration packages", async () => { - // Test that Core still works - const coreParams = { - to: "test@example.com", - subject: "Test", - body: "Test body", - }; - - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, - body: coreParams, - status: 200, - response: { success: true }, - }); - - const coreResult = await base44.integrations.Core.SendEmail(coreParams); - expect(coreResult.success).toBe(true); - - // Test that custom packages still work - const customPackageParams = { param: "value" }; - - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integration-endpoints/installable/SomePackage/integration-endpoints/SomeEndpoint`, - body: customPackageParams, - status: 200, - response: { success: true }, - }); - - const packageResult = - await base44.integrations.SomePackage.SomeEndpoint(customPackageParams); - expect(packageResult.success).toBe(true); + test("custom property does not interfere with other integration packages", async () => { + platform.given.integrations.emailDelivered(); + // Legacy SDK compatibility; current Apper has removed this route. + platform.given.integrations.packageSucceeds("SomePackage", "SomeEndpoint"); + await expect(base44.integrations.Core.SendEmail({ to: "test@example.com", subject: "Test", body: "Test body" })).resolves.toMatchObject({ success: true }); + await expect(base44.integrations.SomePackage.SomeEndpoint({ param: "value" })).resolves.toMatchObject({ success: true }); + expect(platform.requests.count("integrations.invoke")).toBe(2); }); }); diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts index 2deade29..6187ddc6 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -1,22 +1,15 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { createClient } from "../../src/index.ts"; -import type { - DeleteResult, - UpdateManyResult, -} from "../../src/modules/entities.types.ts"; - -/** - * Todo entity type for testing. - */ +import { platform } from "../mocks/platform"; + interface Todo { id: string; title: string; completed: boolean; - description?: string; + description?: string | null; + view_count?: number; } -// Module augmentation: register Todo type in EntityTypeRegistry declare module "../../src/modules/entities.types.ts" { interface EntityTypeRegistry { Todo: Todo; @@ -25,293 +18,207 @@ declare module "../../src/modules/entities.types.ts" { describe("Entities Module", () => { let base44: ReturnType; - const appId = "test-app-id"; - const serverUrl = "https://api.base44.com"; beforeEach(() => { - // Create a new client for each test + platform.reset(); base44 = createClient({ - serverUrl, - appId, + serverUrl: "https://api.base44.com", + appId: "test-app-id", }); }); - afterEach(() => { - base44.cleanup(); - }); + afterEach(() => base44.cleanup()); - test("list() should fetch entities with correct parameters", async () => { - const mockTodos: Todo[] = [ + test("list() fetches arranged entities with the correct parameters", async () => { + platform.given.entities.records("Todo", [ { id: "1", title: "Task 1", completed: false }, { id: "2", title: "Task 2", completed: true }, - ]; - - // Mock the API response - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - query: true, - status: 200, - response: mockTodos, - }); + ]); - // Call the API const result = await base44.entities.Todo.list("title", 10, 0, [ "id", "title", ]); - // Verify the response - expect(result).toHaveLength(2); - expect(result[0].title).toBe("Task 1"); - }); - - test("filter() should send correct query parameters", async () => { - const filterQuery: Partial = { completed: true }; - const mockTodos: Todo[] = [{ id: "2", title: "Task 2", completed: true }]; - - // Mock the API response - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - query: (query) => { - // Verify the query contains our filter - const parsedQ = JSON.parse(query.q as string); - return parsedQ.completed === true; - }, - status: 200, - response: mockTodos, + expect(result).toEqual([ + { id: "1", title: "Task 1" }, + { id: "2", title: "Task 2" }, + ]); + expect(platform.requests.last("entities.list").query).toEqual({ + fields: "id,title", + limit: "10", + sort: "title", }); + }); - // Call the API - const result = await base44.entities.Todo.filter(filterQuery); + test("list() retains id when a field projection omits it", async () => { + platform.given.entities.records("Todo", [ + { id: "1", title: "Projected", completed: false }, + ]); - // Verify the response - expect(result).toHaveLength(1); - expect(result[0].completed).toBe(true); + await expect( + base44.entities.Todo.list(undefined, undefined, undefined, ["title"]), + ).resolves.toEqual([{ id: "1", title: "Projected" }]); }); - test("filter() should support typed advanced query syntax", async () => { - const mockTodos: Todo[] = [{ id: "2", title: "Task 2", completed: true }]; - - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - query: (query) => { - const parsedQ = JSON.parse(query.q as string); - - return ( - parsedQ.title.$in[0] === "Task 1" && - parsedQ.title.$in[1] === "Task 2" && - parsedQ.description === null && - parsedQ.$or[0].title === "Task 2" && - parsedQ.$or[1].completed === true - ); - }, - status: 200, - response: mockTodos, + test("filter() sends the query and returns matching domain state", async () => { + platform.given.entities.records("Todo", [ + { id: "1", title: "Task 1", completed: false }, + { id: "2", title: "Task 2", completed: true }, + ]); + + const result = await base44.entities.Todo.filter({ completed: true }); + + expect(result).toEqual([{ id: "2", title: "Task 2", completed: true }]); + expect(JSON.parse(platform.requests.last("entities.list").query.q)).toEqual({ + completed: true, }); + }); - const result = await base44.entities.Todo.filter({ + test("filter() supports typed advanced query syntax", async () => { + platform.given.entities.records("Todo", [ + { id: "1", title: "Task 1", completed: false, description: "notes" }, + { id: "2", title: "Task 2", completed: true, description: null }, + ]); + const query = { title: { $in: ["Task 1", "Task 2"] }, description: null, $or: [{ title: "Task 2" }, { completed: true }], - }); + }; + + const result = await base44.entities.Todo.filter(query); expect(result).toHaveLength(1); + expect(result[0].id).toBe("2"); + expect(JSON.parse(platform.requests.last("entities.list").query.q)).toEqual(query); }); - test("get() should fetch a single entity", async () => { - const todoId = "123"; - const mockTodo: Todo = { - id: todoId, + test("get() fetches one arranged entity", async () => { + platform.given.entities.records("Todo", [ + { id: "123", title: "Get milk", completed: false }, + ]); + + await expect(base44.entities.Todo.get("123")).resolves.toEqual({ + id: "123", title: "Get milk", completed: false, - }; - - // Mock the API response - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/entities/Todo/${todoId}`, - status: 200, - response: mockTodo, }); - - // Call the API - const todo = await base44.entities.Todo.get(todoId); - - // Verify the response - expect(todo.id).toBe(todoId); - expect(todo.title).toBe("Get milk"); }); - test("create() should send correct data", async () => { - const newTodo: Partial = { + test("create() persists so subsequent get() and list() observe the record", async () => { + platform.given.entities.records("Todo", []); + + const created = await base44.entities.Todo.create({ title: "New task", completed: false, - }; - const createdTodo: Todo = { - id: "123", + }); + + expect(created).toEqual({ id: "1", title: "New task", completed: false }); + await expect(base44.entities.Todo.get(created.id)).resolves.toEqual(created); + await expect(base44.entities.Todo.list()).resolves.toContainEqual(created); + expect(platform.requests.last("entities.create").body).toEqual({ title: "New task", completed: false, - }; - - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/entities/Todo`, - body: newTodo, - status: 201, - response: createdTodo, }); - - // Call the API - const todo = await base44.entities.Todo.create(newTodo); - - // Verify the response - expect(todo.id).toBe("123"); - expect(todo.title).toBe("New task"); }); - test("update() should send correct data", async () => { - const todoId = "123"; - const updates: Partial = { + test("update() changes the stored entity", async () => { + platform.given.entities.records("Todo", [ + { id: "123", title: "Old task", completed: false }, + ]); + + const updated = await base44.entities.Todo.update("123", { title: "Updated task", completed: true, - }; - const updatedTodo: Todo = { - id: todoId, + }); + + expect(updated).toEqual({ id: "123", title: "Updated task", completed: true }); + await expect(base44.entities.Todo.get("123")).resolves.toEqual(updated); + expect(platform.requests.last("entities.update").body).toEqual({ title: "Updated task", completed: true, - }; - - // Mock the API response - mockHttp({ - method: "put", - url: serverUrl + `/api/apps/${appId}/entities/Todo/${todoId}`, - body: updates, - status: 200, - response: updatedTodo, }); - - // Call the API - const todo = await base44.entities.Todo.update(todoId, updates); - - // Verify the response - expect(todo.id).toBe(todoId); - expect(todo.title).toBe("Updated task"); - expect(todo.completed).toBe(true); }); - test("delete() should call correct endpoint and return DeleteResult", async () => { - const todoId = "123"; - const deleteResult: DeleteResult = { success: true }; - - // Mock the API response - mockHttp({ - method: "delete", - url: serverUrl + `/api/apps/${appId}/entities/Todo/${todoId}`, - status: 200, - response: deleteResult, - }); - - // Call the API - const result = await base44.entities.Todo.delete(todoId); + test("delete() removes the stored entity and returns DeleteResult", async () => { + platform.given.entities.records("Todo", [ + { id: "123", title: "Delete me", completed: false }, + ]); - // Verify the response matches DeleteResult type - expect(result.success).toBe(true); + await expect(base44.entities.Todo.delete("123")).resolves.toEqual({ success: true }); + await expect(base44.entities.Todo.list()).resolves.toEqual([]); }); - test("updateMany() should send query and data to correct endpoint", async () => { - const mockResult: UpdateManyResult = { - success: true, - updated: 3, - has_more: false, - }; - - // Mock the API response - mockHttp({ - method: "patch", - url: serverUrl + `/api/apps/${appId}/entities/Todo/update-many`, - body: { - query: { completed: false }, - data: { $set: { completed: true } }, - }, - status: 200, - response: mockResult, - }); + test("updateMany() applies update operators to matching records", async () => { + platform.given.entities.records("Todo", [ + { id: "1", title: "One", completed: false }, + { id: "2", title: "Two", completed: false }, + { id: "3", title: "Three", completed: false }, + { id: "4", title: "Done", completed: true }, + ]); - // Call the API const result = await base44.entities.Todo.updateMany( { completed: false }, { $set: { completed: true } }, ); - // Verify the response - expect(result.success).toBe(true); - expect(result.updated).toBe(3); - expect(result.has_more).toBe(false); + expect(result).toEqual({ success: true, updated: 3, has_more: false }); + expect(await base44.entities.Todo.filter({ completed: true })).toHaveLength(4); + expect(platform.requests.last("entities.updateMany").body).toEqual({ + query: { completed: false }, + data: { $set: { completed: true } }, + }); }); - test("updateMany() should handle has_more response", async () => { - const mockResult: UpdateManyResult = { - success: true, - updated: 500, - has_more: true, - }; - - // Mock the API response - mockHttp({ - method: "patch", - url: serverUrl + `/api/apps/${appId}/entities/Todo/update-many`, - body: { - query: {}, - data: { $inc: { view_count: 1 } }, - }, - status: 200, - response: mockResult, - }); + test("updateMany() reports has_more at the platform batch limit", async () => { + platform.given.entities.records( + "Todo", + Array.from({ length: 501 }, (_, index) => ({ + id: String(index + 1), + title: `Task ${index + 1}`, + completed: false, + view_count: 0, + })), + ); - // Call the API const result = await base44.entities.Todo.updateMany( {}, { $inc: { view_count: 1 } }, ); - // Verify the response - expect(result.success).toBe(true); - expect(result.updated).toBe(500); - expect(result.has_more).toBe(true); + expect(result).toEqual({ success: true, updated: 500, has_more: true }); + expect((await base44.entities.Todo.get("1")).view_count).toBe(1); + expect((await base44.entities.Todo.get("501")).view_count).toBe(0); }); - test("bulkUpdate() should send array of updates to correct endpoint", async () => { - const updatePayload = [ + test("bulkUpdate() updates records without dropping existing fields", async () => { + platform.given.entities.records("Todo", [ + { id: "1", title: "Task 1", completed: false }, + { id: "2", title: "Task 2", completed: false }, + ]); + const updates = [ { id: "1", title: "Updated Task 1", completed: true }, { id: "2", title: "Updated Task 2" }, ]; - const mockResponse: Todo[] = [ + + const result = await base44.entities.Todo.bulkUpdate(updates); + + expect(result).toEqual([ { id: "1", title: "Updated Task 1", completed: true }, { id: "2", title: "Updated Task 2", completed: false }, - ]; + ]); + expect(platform.requests.last("entities.bulkUpdate").body).toEqual(updates); + }); - // Mock the API response - mockHttp({ - method: "put", - url: serverUrl + `/api/apps/${appId}/entities/Todo/bulk`, - body: updatePayload, - status: 200, - response: mockResponse, - }); + test("reset() isolates platform state and request history", async () => { + platform.given.entities.records("Todo", [ + { id: "1", title: "Transient", completed: false }, + ]); + await base44.entities.Todo.list(); - // Call the API - const result = await base44.entities.Todo.bulkUpdate(updatePayload); + platform.reset(); - // Verify the response - expect(result).toHaveLength(2); - expect(result[0].id).toBe("1"); - expect(result[0].title).toBe("Updated Task 1"); - expect(result[0].completed).toBe(true); - expect(result[1].id).toBe("2"); - expect(result[1].title).toBe("Updated Task 2"); + await expect(base44.entities.Todo.list()).resolves.toEqual([]); + expect(platform.requests.count("entities.list")).toBe(1); }); }); diff --git a/tests/unit/fetch-with-auth.test.ts b/tests/unit/fetch-with-auth.test.ts index d1a281b6..f7bfe139 100644 --- a/tests/unit/fetch-with-auth.test.ts +++ b/tests/unit/fetch-with-auth.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { http, HttpResponse } from "msw"; -import { server } from "../mocks/server"; import { createClient, createClientFromRequest as fromRequest } from "../../src/index.ts"; +import { platform } from "../mocks/platform"; const appId = "test-app-id"; const origin = "https://my-app.base44.app"; @@ -40,7 +39,6 @@ function stubBrowser(storage = makeLocalStorage()) { // Node has no browser-relative fetch base. This injected platform adapter resolves // the URL, then uses real fetch intercepted by MSW; it never fabricates a response. const transportCalls: Array<[string, RequestInit]> = []; -let requests: Request[] = []; const clients: Array> = []; const createClientFromRequest = (request: Request) => { const client = fromRequest(request); @@ -60,11 +58,8 @@ const createTestClient = (token?: string) => { }; beforeEach(() => { transportCalls.length = 0; - requests = []; - server.use(http.all(`${origin}/api/*`, ({request}) => { - requests.push(request.clone()); - return HttpResponse.json({}); - })); + for (const path of ["/api/orders", "/api/public", "/api/items"]) + platform.given.generic.route(path); }); afterEach(() => { for (const client of clients.splice(0)) client.cleanup(); @@ -72,9 +67,10 @@ afterEach(() => { vi.clearAllMocks(); }); const lastCall = () => { + const requests = platform.requests.all("generic.request"); expect(requests).toHaveLength(1); const [url, init] = transportCalls[0]!; - return {url, init, headers: requests[0]!.headers}; + return { url, init, request: requests[0]!, headers: new Headers(requests[0]!.headers) }; }; describe("fetchWithAuth", () => { @@ -127,7 +123,7 @@ describe("fetchWithAuth", () => { await base44.fetchWithAuth("/api/public"); expect(lastCall().headers.get("Authorization")).toBeNull(); - expect(requests).toHaveLength(1); + expect(platform.requests.count("generic.request")).toBe(1); }); test("forwards init options and keeps a caller-set Authorization header", async () => { @@ -146,7 +142,7 @@ describe("fetchWithAuth", () => { const { init, headers } = lastCall(); expect(init.method).toBe("POST"); expect(init.body).toBe(JSON.stringify({ productId: "abc" })); - expect(await requests[0]!.json()).toEqual({productId: "abc"}); + expect(lastCall().request.body).toEqual({ productId: "abc" }); expect(headers.get("Content-Type")).toBe("application/json"); expect(headers.get("Authorization")).toBe("Bearer caller-token"); }); @@ -179,7 +175,7 @@ describe("fetchWithAuth", () => { /only sends requests to your app's own origin/ ); expect(transportCalls).toHaveLength(0); - expect(requests).toHaveLength(0); + expect(platform.requests.count("generic.request")).toBe(0); }); test("rejects an empty path", async () => { @@ -188,7 +184,7 @@ describe("fetchWithAuth", () => { await expect(base44.fetchWithAuth("")).rejects.toThrow(/requires a path/); expect(transportCalls).toHaveLength(0); - expect(requests).toHaveLength(0); + expect(platform.requests.count("generic.request")).toBe(0); }); test("works with no document, as in a server route", async () => { @@ -209,7 +205,7 @@ describe("fetchWithAuth", () => { base44.fetchWithAuth("https://evil.example/steal") ).rejects.toThrow(/only sends requests to your app's own origin/); expect(transportCalls).toHaveLength(0); - expect(requests).toHaveLength(0); + expect(platform.requests.count("generic.request")).toBe(0); }); }); @@ -327,7 +323,7 @@ describe("fetchWithAuth from a server route", () => { await base44.fetchWithAuth("/api/items", { fetch: transport }); expect(transportCalls).toHaveLength(1); - expect(requests).toHaveLength(1); + expect(platform.requests.count("generic.request")).toBe(1); expect(lastCall().init).not.toHaveProperty("fetch"); }); @@ -338,7 +334,7 @@ describe("fetchWithAuth from a server route", () => { base44.fetchWithAuth("https://evil.example/steal", { fetch: transport }) ).rejects.toThrow(/only sends requests to your app's own origin/); expect(transportCalls).toHaveLength(0); - expect(requests).toHaveLength(0); + expect(platform.requests.count("generic.request")).toBe(0); }); }); diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index 7f332096..059287b5 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -1,7 +1,5 @@ -import { mockHttp } from "../mocks/http"; -import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; -import { http, HttpResponse } from "msw"; -import { server } from "../mocks/server"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { platform, type MultipartBody } from "../mocks/platform"; import { createClient } from "../../src/index.ts"; // Module augmentation: register function names in FunctionNameRegistry @@ -38,17 +36,9 @@ describe("Functions Module", () => { priority: "high", }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: functionData, - headers: [["Content-Type", "application/json"]], - status: 200, - response: { - success: true, - messageId: "msg-456", - }, + platform.given.functions.result(functionName, { + success: true, + messageId: "msg-456", }); // Call the function @@ -57,22 +47,17 @@ describe("Functions Module", () => { // Verify the response expect(result.data.success).toBe(true); expect(result.data.messageId).toBe("msg-456"); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should handle function with empty object parameters", async () => { const functionName = "getStatus"; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: {}, - headers: [["Content-Type", "application/json"]], - status: 200, - response: { - status: "healthy", - timestamp: "2024-01-01T00:00:00Z", - }, + platform.given.functions.result(functionName, { + status: "healthy", + timestamp: "2024-01-01T00:00:00Z", }); // Call the function @@ -80,6 +65,7 @@ describe("Functions Module", () => { // Verify the response expect(result.data.status).toBe("healthy"); + expect(platform.requests.last("functions.invoke").body).toEqual({}); }); test("should handle function with complex nested objects", async () => { @@ -101,17 +87,9 @@ describe("Functions Module", () => { }, }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: functionData, - headers: [["Content-Type", "application/json"]], - status: 200, - response: { - processed: true, - userId: "123", - }, + platform.given.functions.result(functionName, { + processed: true, + userId: "123", }); // Call the function @@ -119,6 +97,9 @@ describe("Functions Module", () => { // Verify the response expect(result.data.processed).toBe(true); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should handle file uploads with FormData", async () => { @@ -130,28 +111,10 @@ describe("Functions Module", () => { category: "documents", }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - headers: [["Content-Type", /^multipart\/form-data/]], - inspect: async (request) => { - const form = await request.formData(); - const file = form.get("file") as File; - expect(file.name).toBe("test.txt"); - expect(file.type).toBe("text/plain"); - expect(await file.text()).toBe("test content"); - }, - respond: () => { - return [ - 200, - { - fileId: "file-789", - filename: "test.txt", - size: 12, - }, - ]; - }, + platform.given.functions.result(functionName, { + fileId: "file-789", + filename: "test.txt", + size: 12, }); // Call the function @@ -160,6 +123,17 @@ describe("Functions Module", () => { // Verify the response expect(result.data.fileId).toBe("file-789"); expect(result.data.filename).toBe("test.txt"); + const body = platform.requests.last("functions.invoke") + .body as MultipartBody; + expect(body.entries).toContainEqual({ + name: "file", + file: { + name: "test.txt", + type: "text/plain", + size: 12, + bytes: [...new TextEncoder().encode("test content")], + }, + }); }); test("should handle mixed data with files and regular data", async () => { @@ -177,28 +151,10 @@ describe("Functions Module", () => { priority: "high", }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - headers: [["Content-Type", /^multipart\/form-data/]], - inspect: async (request) => { - const form = await request.formData(); - const file = form.get("file") as File; - expect(file.name).toBe("document.pdf"); - expect(file.type).toBe("application/pdf"); - expect(await file.text()).toBe("document content"); - expect(JSON.parse(form.get("metadata") as string)).toEqual( - functionData.metadata, - ); - expect(form.get("priority")).toBe("high"); - }, - status: 200, - response: { - documentId: "doc-123", - processed: true, - extractedText: "document content", - }, + platform.given.functions.result(functionName, { + documentId: "doc-123", + processed: true, + extractedText: "document content", }); // Call the function @@ -207,6 +163,23 @@ describe("Functions Module", () => { // Verify the response expect(result.data.documentId).toBe("doc-123"); expect(result.data.processed).toBe(true); + const body = platform.requests.last("functions.invoke") + .body as MultipartBody; + expect(body.entries).toEqual( + expect.arrayContaining([ + { + name: "file", + file: { + name: "document.pdf", + type: "application/pdf", + size: 16, + bytes: [...new TextEncoder().encode("document content")], + }, + }, + { name: "metadata", value: JSON.stringify(functionData.metadata) }, + { name: "priority", value: "high" }, + ]), + ); }); test("should handle FormData input directly", async () => { @@ -216,23 +189,9 @@ describe("Functions Module", () => { formData.append("email", "john@example.com"); formData.append("message", "Hello there"); - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - headers: [["Content-Type", /^multipart\/form-data/]], - inspect: async (request) => { - expect(Object.fromEntries(await request.formData())).toEqual({ - name: "John Doe", - email: "john@example.com", - message: "Hello there", - }); - }, - status: 200, - response: { - formId: "form-456", - submitted: true, - }, + platform.given.functions.result(functionName, { + formId: "form-456", + submitted: true, }); // Call the function @@ -241,6 +200,14 @@ describe("Functions Module", () => { // Verify the response expect(result.data.formId).toBe("form-456"); expect(result.data.submitted).toBe(true); + expect( + (platform.requests.last("functions.invoke").body as MultipartBody) + .entries, + ).toEqual([ + { name: "name", value: "John Doe" }, + { name: "email", value: "john@example.com" }, + { name: "message", value: "Hello there" }, + ]); }); test("direct FormData preserves repeated keys, binary files and empty values", async () => { @@ -254,26 +221,28 @@ describe("Functions Module", () => { type: "application/octet-stream", }), ); - mockHttp({ - method: "post", - url: `${serverUrl}/api/apps/${appId}/functions/upload`, - response: { ok: true }, - inspect: async (request) => { - const actual = await request.formData(); - expect(actual.getAll("tag")).toEqual(["one", "two"]); - expect(actual.get("empty")).toBe(""); - const file = actual.get("file") as File; - expect(file.name).toBe("bytes.bin"); - expect(file.type).toBe("application/octet-stream"); - expect([...new Uint8Array(await file.arrayBuffer())]).toEqual([ - 0, 255, 10, - ]); - }, - }); + platform.given.functions.result("upload", { ok: true }); expect((await base44.functions.invoke("upload", form)).data).toEqual({ ok: true, }); expect(form.getAll("tag")).toEqual(["one", "two"]); + expect( + (platform.requests.last("functions.invoke").body as MultipartBody) + .entries, + ).toEqual([ + { name: "tag", value: "one" }, + { name: "tag", value: "two" }, + { name: "empty", value: "" }, + { + name: "file", + file: { + name: "bytes.bin", + type: "application/octet-stream", + size: 3, + bytes: [0, 255, 10], + }, + }, + ]); }); test("should throw error for string input instead of object", async () => { @@ -294,23 +263,16 @@ describe("Functions Module", () => { input: "test data", }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: functionData, - headers: [["Content-Type", "application/json"]], - status: 200, - response: { - processed: true, - }, - }); + platform.given.functions.result(functionName, { processed: true }); // Call the function const result = await base44.functions.invoke(functionName, functionData); // Verify the response expect(result.data.processed).toBe(true); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should handle API errors gracefully", async () => { @@ -319,23 +281,15 @@ describe("Functions Module", () => { param: "value", }; - // Mock the API error response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: functionData, - headers: [["Content-Type", "application/json"]], - status: 500, - response: { - error: "Internal server error", - code: "INTERNAL_ERROR", - }, - }); + platform.given.faults.functions.internalError(functionName); // Call the function and expect it to throw await expect( base44.functions.invoke(functionName, functionData), ).rejects.toThrow(); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should handle 404 errors for non-existent functions", async () => { @@ -344,23 +298,15 @@ describe("Functions Module", () => { param: "value", }; - // Mock the API 404 response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: functionData, - headers: [["Content-Type", "application/json"]], - status: 404, - response: { - error: "Function not found", - code: "FUNCTION_NOT_FOUND", - }, - }); + platform.given.faults.functions.notFound(functionName); // Call the function and expect it to throw await expect( base44.functions.invoke(functionName, functionData), ).rejects.toThrow(); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should handle null and undefined values in data", async () => { @@ -372,17 +318,9 @@ describe("Functions Module", () => { emptyString: "", }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: functionData, - headers: [["Content-Type", "application/json"]], - status: 200, - response: { - received: true, - values: functionData, - }, + platform.given.functions.result(functionName, { + received: true, + values: functionData, }); // Call the function @@ -390,6 +328,11 @@ describe("Functions Module", () => { // Verify the response expect(result.data.received).toBe(true); + expect(platform.requests.last("functions.invoke").body).toEqual({ + stringValue: "test", + nullValue: null, + emptyString: "", + }); }); test("should handle array values in data", async () => { @@ -400,17 +343,9 @@ describe("Functions Module", () => { mixed: [1, "two", { three: 3 }], }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: functionData, - headers: [["Content-Type", "application/json"]], - status: 200, - response: { - processed: true, - count: 3, - }, + platform.given.functions.result(functionName, { + processed: true, + count: 3, }); // Call the function @@ -419,6 +354,9 @@ describe("Functions Module", () => { // Verify the response expect(result.data.processed).toBe(true); expect(result.data.count).toBe(3); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should create FormData correctly when files are present", async () => { @@ -430,20 +368,17 @@ describe("Functions Module", () => { category: "documents", }; - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - headers: [["Content-Type", /^multipart\/form-data/]], - status: 200, - response: { success: true }, - }); + platform.given.functions.result(functionName, { success: true }); // Call the function const result = await base44.functions.invoke(functionName, functionData); // Verify the response expect(result.data.success).toBe(true); + expect( + (platform.requests.last("functions.invoke").body as MultipartBody) + .entries, + ).toContainEqual({ name: "description", value: "Test file upload" }); }); test("should create FormData correctly when FormData is passed directly", async () => { @@ -452,20 +387,20 @@ describe("Functions Module", () => { formData.append("name", "John Doe"); formData.append("email", "john@example.com"); - // Mock the API response - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - headers: [["Content-Type", /^multipart\/form-data/]], - status: 200, - response: { success: true }, - }); + platform.given.functions.result(functionName, { success: true }); // Call the function const result = await base44.functions.invoke(functionName, formData); // Verify the response expect(result.data.success).toBe(true); + expect( + (platform.requests.last("functions.invoke").body as MultipartBody) + .entries, + ).toEqual([ + { name: "name", value: "John Doe" }, + { name: "email", value: "john@example.com" }, + ]); }); test("should send user token as Authorization header when invoking functions", async () => { @@ -482,20 +417,9 @@ describe("Functions Module", () => { token: userToken, }); - // Mock the API response, verifying the Authorization header - mockHttp({ - method: "post", - url: serverUrl + `/api/apps/${appId}/functions/${functionName}`, - body: functionData, - headers: [ - ["Content-Type", "application/json"], - ["Authorization", `Bearer ${userToken}`], - ], - status: 200, - response: { - success: true, - authenticated: true, - }, + platform.given.functions.result(functionName, { + success: true, + authenticated: true, }); // Call the function @@ -507,20 +431,20 @@ describe("Functions Module", () => { // Verify the response expect(result.data.success).toBe(true); expect(result.data.authenticated).toBe(true); + expect( + platform.requests.last("functions.invoke").headers.authorization, + ).toBe(`Bearer ${userToken}`); + authenticatedBase44.cleanup(); }); test("should fetch function endpoint directly", async () => { - let capturedUrl: string | null = null; - server.use( - http.get(`${serverUrl}/api/functions/my_function`, ({ request }) => { - capturedUrl = request.url; - return new HttpResponse("ok", { status: 200 }); - }), - ); + platform.given.functions.raw("my_function"); await base44.functions.fetch("/my_function", { method: "GET" }); - expect(capturedUrl).toBe(`${serverUrl}/api/functions/my_function`); + expect(platform.requests.last("functions.fetch").url).toBe( + `${serverUrl}/api/functions/my_function`, + ); }); test("should include Authorization header when using functions.fetch", async () => { @@ -531,58 +455,48 @@ describe("Functions Module", () => { token: userToken, }); - let capturedAuth: string | null = null; - server.use( - http.post(`${serverUrl}/api/functions/streaming_demo`, ({ request }) => { - capturedAuth = request.headers.get("Authorization"); - return new HttpResponse("ok", { status: 200 }); - }), - ); + platform.given.functions.raw("streaming_demo"); await authenticatedBase44.functions.fetch("streaming_demo", { method: "POST", body: JSON.stringify({ mode: "text" }), }); - expect(capturedAuth).toBe(`Bearer ${userToken}`); + const request = platform.requests.last("functions.fetch"); + expect(request.headers.authorization).toBe(`Bearer ${userToken}`); + expect(request.body).toBe(JSON.stringify({ mode: "text" })); authenticatedBase44.cleanup(); }); test("should normalize path with and without leading slash", async () => { - const calledUrls: string[] = []; - server.use( - http.get(`${serverUrl}/api/functions/my_function`, ({ request }) => { - calledUrls.push(request.url); - return new HttpResponse("ok", { status: 200 }); - }), - ); + platform.given.functions.raw("my_function"); await base44.functions.fetch("/my_function"); await base44.functions.fetch("my_function"); - expect(calledUrls).toHaveLength(2); - expect(calledUrls[0]).toBe(`${serverUrl}/api/functions/my_function`); - expect(calledUrls[1]).toBe(`${serverUrl}/api/functions/my_function`); + const calledUrls = platform.requests + .all("functions.fetch") + .map((request) => request.url); + expect(calledUrls).toEqual([ + `${serverUrl}/api/functions/my_function`, + `${serverUrl}/api/functions/my_function`, + ]); }); test("should include service role Authorization header when using asServiceRole.functions.fetch", async () => { const serviceToken = "service-role-token"; const serviceRoleBase44 = createClient({ serverUrl, appId, serviceToken }); - let capturedAuth: string | null = null; - server.use( - http.get(`${serverUrl}/api/functions/service_function`, ({ request }) => { - capturedAuth = request.headers.get("Authorization"); - return new HttpResponse("ok", { status: 200 }); - }), - ); + platform.given.functions.raw("service_function"); await serviceRoleBase44.asServiceRole.functions.fetch("/service_function", { method: "GET", }); - expect(capturedAuth).toBe(`Bearer ${serviceToken}`); + expect( + platform.requests.last("functions.fetch").headers.authorization, + ).toBe(`Bearer ${serviceToken}`); serviceRoleBase44.cleanup(); }); diff --git a/tests/unit/integrations.test.js b/tests/unit/integrations.test.js index dfa20e92..fbdeea7e 100644 --- a/tests/unit/integrations.test.js +++ b/tests/unit/integrations.test.js @@ -1,6 +1,6 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform/index.ts"; describe("Integrations Module", () => { let base44; @@ -8,124 +8,67 @@ describe("Integrations Module", () => { const serverUrl = "https://base44.app"; beforeEach(() => { - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - }); - }); - - afterEach(() => { - base44.cleanup(); + base44 = createClient({ serverUrl, appId }); }); - - test("Core integration should send requests to the correct endpoint", async () => { - const emailParams = { - to: "test@example.com", - subject: "Test Email", - body: "This is a test email", - }; - - // Mock the API response - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, - body: emailParams, - status: 200, - response: { success: true, messageId: "123456" }, + afterEach(() => base44.cleanup()); + + test("Core integration sends named parameters to the endpoint", async () => { + platform.given.integrations.emailDelivered("123456"); + const email = { to: "test@example.com", subject: "Test Email", body: "This is a test email" }; + const result = await base44.integrations.Core.SendEmail(email); + expect(result).toEqual({ success: true, messageId: "123456" }); + expect(platform.requests.last("integrations.invoke")).toMatchObject({ + method: "POST", body: email, + url: `${serverUrl}/api/apps/${appId}/integration-endpoints/Core/SendEmail`, }); - - // Call the API - const result = await base44.integrations.Core.SendEmail(emailParams); - - // Verify the response - expect(result.success).toBe(true); - expect(result.messageId).toBe("123456"); }); - test("Custom package integration should send requests to the correct endpoint", async () => { - const customParams = { - param1: "value1", - param2: "value2", - }; - - // Mock the API response - mockHttp({ - method: "post", - url: - serverUrl + - `/api/apps/${appId}/integration-endpoints/installable/CustomPackage/integration-endpoints/CustomEndpoint`, - body: customParams, - status: 200, - response: { success: true, result: "custom result" }, + test("Legacy custom package integration sends requests to its installable endpoint", async () => { + // Kept for the SDK's backwards-compatible dynamic package API. Current Apper + // no longer exposes installable-package integrations. + platform.given.integrations.packageSucceeds("CustomPackage", "CustomEndpoint", { result: "custom result" }); + const params = { param1: "value1", param2: "value2" }; + const result = await base44.integrations.CustomPackage.CustomEndpoint(params); + expect(result).toEqual({ success: true, result: "custom result" }); + expect(platform.requests.last("integrations.invoke")).toMatchObject({ + method: "POST", body: params, + url: `${serverUrl}/api/apps/${appId}/integration-endpoints/installable/CustomPackage/integration-endpoints/CustomEndpoint`, }); - - // Call the API - const result = - await base44.integrations.CustomPackage.CustomEndpoint(customParams); - - // Verify the response - expect(result.success).toBe(true); - expect(result.result).toBe("custom result"); }); - test("Integration should handle file uploads correctly", async () => { - // Mock a file - const mockFile = new Blob(["file content"], { type: "text/plain" }); - mockFile.name = "test.txt"; - - const uploadParams = { - file: mockFile, - metadata: { type: "document" }, - }; - - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/UploadFile`, - status: 200, - response: { success: true, fileId: "file123" }, + test("Integration serializes file uploads as multipart data", async () => { + platform.given.integrations.fileUploaded("file123"); + const file = new File(["file content"], "test.txt", { type: "text/plain" }); + const result = await base44.integrations.Core.UploadFile({ file, metadata: { type: "document" } }); + expect(result).toEqual({ success: true, fileId: "file123" }); + expect(platform.requests.last("integrations.invoke")).toMatchObject({ + method: "POST", + body: { + type: "multipart", + entries: expect.arrayContaining([ + { name: "file", file: { name: "test.txt", type: "text/plain", size: 12, bytes: [...new TextEncoder().encode("file content")] } }, + { name: "metadata", value: '{"type":"document"}' }, + ]), + }, }); - - // Call the API - const result = await base44.integrations.Core.UploadFile(uploadParams); - - // Verify the response - expect(result.success).toBe(true); - expect(result.fileId).toBe("file123"); }); - test("Integration should throw error with string parameters", async () => { - // Expect error when trying to call with a string instead of object - await expect(async () => { - await base44.integrations.Core.SendEmail("invalid string parameter"); - }).rejects.toThrow( + test("Integration rejects string parameters before making a request", async () => { + await expect(base44.integrations.Core.SendEmail("invalid string parameter")).rejects.toThrow( "Integration SendEmail must receive an object with named parameters", ); + expect(platform.requests.count("integrations.invoke")).toBe(0); }); - test("Integration should handle API errors correctly", async () => { - const params = { invalid: "params" }; - - // Mock an API error response - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/SendEmail`, - body: params, - status: 400, - response: { detail: "Invalid parameters", code: "INVALID_PARAMS" }, + test("Integration maps a named invalid-parameters platform fault", async () => { + platform.given.integrations.emailDelivered("after-retry"); + platform.given.faults.integrations.invalidParameters("Core", "SendEmail"); + await expect(base44.integrations.Core.SendEmail({ invalid: "params" })).rejects.toMatchObject({ + status: 400, name: "Base44Error", message: "Invalid parameters", code: "INVALID_PARAMS", }); - - // Call the API and expect an error - await expect( - base44.integrations.Core.SendEmail(params), - ).rejects.toMatchObject({ - status: 400, - name: "Base44Error", - message: "Invalid parameters", - code: "INVALID_PARAMS", + await expect(base44.integrations.Core.SendEmail({ to: "valid@example.com" })).resolves.toEqual({ + success: true, + messageId: "after-retry", }); }); }); diff --git a/tests/unit/integrations.test.ts b/tests/unit/integrations.test.ts index 13a6af2b..9ffecea1 100644 --- a/tests/unit/integrations.test.ts +++ b/tests/unit/integrations.test.ts @@ -1,84 +1,37 @@ -import { mockHttp } from "../mocks/http"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform/index.ts"; describe("Core Integrations - InvokeLLM", () => { let base44: ReturnType; - const appId = "test-app-id"; - const serverUrl = "https://base44.app"; - beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - }); - }); - - afterEach(() => { - base44.cleanup(); + base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id" }); }); + afterEach(() => base44.cleanup()); - test("InvokeLLM should pass model parameter to the API", async () => { - const params = { - prompt: "Explain quantum computing", - model: "gpt_5", - }; - - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, - body: params, - status: 200, - response: "Quantum computing uses qubits...", - }); - - const result = await base44.integrations.Core.InvokeLLM(params); - expect(result).toBe("Quantum computing uses qubits..."); + test("passes model parameter to the API", async () => { + platform.given.integrations.llmResponds("Quantum computing uses qubits..."); + const params = { prompt: "Explain quantum computing", model: "gpt_5" }; + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe("Quantum computing uses qubits..."); + expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); - test("InvokeLLM should work without model parameter", async () => { - const params = { - prompt: "Explain quantum computing", - }; - - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, - body: params, - status: 200, - response: "Quantum computing uses qubits...", - }); - - const result = await base44.integrations.Core.InvokeLLM(params); - expect(result).toBe("Quantum computing uses qubits..."); + test("works without model parameter", async () => { + platform.given.integrations.llmResponds("Quantum computing uses qubits..."); + const params = { prompt: "Explain quantum computing" }; + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe("Quantum computing uses qubits..."); + expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); - test("InvokeLLM should pass model alongside other optional parameters", async () => { + test("passes model alongside other optional parameters", async () => { + const response = { sentiment: "positive" }; + platform.given.integrations.llmResponds(response); const params = { prompt: "Analyze this text", model: "claude_sonnet_4_6" as const, - response_json_schema: { - type: "object", - properties: { - sentiment: { type: "string" }, - }, - }, + response_json_schema: { type: "object", properties: { sentiment: { type: "string" } } }, }; - - const mockResponse = { sentiment: "positive" }; - - mockHttp({ - method: "post", - url: - serverUrl + `/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, - body: params, - status: 200, - response: mockResponse, - }); - - const result = await base44.integrations.Core.InvokeLLM(params); - expect(result).toEqual(mockResponse); + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toEqual(response); + expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); }); diff --git a/tests/unit/mock-platform-architecture.test.ts b/tests/unit/mock-platform-architecture.test.ts new file mode 100644 index 00000000..d6a80868 --- /dev/null +++ b/tests/unit/mock-platform-architecture.test.ts @@ -0,0 +1,30 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +const unitDirectory = fileURLToPath(new URL(".", import.meta.url)); +const thisFile = fileURLToPath(import.meta.url); + +describe("mock platform architecture", () => { + test("SDK behavior tests cannot author HTTP handlers or responses", () => { + const forbidden = [ + ["mock", "Http"].join(""), + ["server", ".use("].join(""), + ["from ", '"msw"'].join(""), + ["from ", "'msw'"].join(""), + ["mocks/", "server"].join(""), + ]; + const violations = readdirSync(unitDirectory) + .filter((name) => /\.test\.[jt]s$/.test(name)) + .map((name) => `${unitDirectory}/${name}`) + .filter((path) => path !== thisFile) + .flatMap((path) => { + const source = readFileSync(path, "utf8"); + return forbidden + .filter((pattern) => source.includes(pattern)) + .map((pattern) => `${path.split("/").at(-1)} contains ${pattern}`); + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/tests/unit/sso.test.ts b/tests/unit/sso.test.ts index 14e19725..ddbf7df3 100644 --- a/tests/unit/sso.test.ts +++ b/tests/unit/sso.test.ts @@ -1,6 +1,6 @@ -import { mockHttp } from "../mocks/http"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform"; describe("SSO module", () => { const appId = "test-app-id"; @@ -11,89 +11,34 @@ describe("SSO module", () => { let base44: ReturnType; beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - token: userToken, - serviceToken, + platform.given.sso.tokens(userId, { + idToken: "header.payload.signature", + accessToken: "access-token-123", }); + base44 = createClient({ serverUrl, appId, token: userToken, serviceToken }); }); - afterEach(() => { - base44.cleanup(); - }); + afterEach(() => base44.cleanup()); test("getIdToken issues the app-scoped GET request and returns the raw token", async () => { - const rawIdToken = "header.payload.signature"; - - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/auth/sso/idtoken/${userId}`, - status: 200, - response: JSON.stringify(rawIdToken), - responseHeaders: { - "Content-Type": "application/json", - }, - }); - - const idToken: string = await base44.asServiceRole.sso.getIdToken(userId); - - expect(idToken).toBe(rawIdToken); + await expect(base44.asServiceRole.sso.getIdToken(userId)).resolves.toBe("header.payload.signature"); + expect(platform.requests.last("sso.getIdToken").url).toContain(`/api/apps/${appId}/auth/sso/idtoken/${userId}`); }); test("getAccessToken issues the existing GET request and returns the raw token", async () => { - const rawAccessToken = "access-token-123"; - - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/auth/sso/accesstoken/${userId}`, - status: 200, - response: JSON.stringify(rawAccessToken), - responseHeaders: { - "Content-Type": "application/json", - }, - }); - - const accessToken = await base44.asServiceRole.sso.getAccessToken(userId); - - // Preserve the legacy public response type for compatibility while - // locking down the endpoint's existing raw-string runtime behavior. - expect(accessToken).toBe(rawAccessToken); + await expect(base44.asServiceRole.sso.getAccessToken(userId)).resolves.toBe("access-token-123"); }); test("getIdToken uses the service-role client with on-behalf-of authentication", async () => { - const rawIdToken = "raw-id-token"; - - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/auth/sso/idtoken/${userId}`, - headers: [ - ["Authorization", `Bearer ${serviceToken}`], - ["on-behalf-of", `Bearer ${userToken}`], - ], - status: 200, - response: JSON.stringify(rawIdToken), - responseHeaders: { - "Content-Type": "application/json", - }, - }); - - const idToken = await base44.asServiceRole.sso.getIdToken(userId); - - expect(idToken).toBe(rawIdToken); + await base44.asServiceRole.sso.getIdToken(userId); + const request = platform.requests.last("sso.getIdToken"); + expect(request.headers.authorization).toBe(`Bearer ${serviceToken}`); + expect(request.headers["on-behalf-of"]).toBe(`Bearer ${userToken}`); }); test("getIdToken surfaces a 404 when no ID token is stored", async () => { - mockHttp({ - method: "get", - url: serverUrl + `/api/apps/${appId}/auth/sso/idtoken/${userId}`, - status: 404, - response: { detail: "No ID token stored", code: "NOT_FOUND" }, - }); - - await expect( - base44.asServiceRole.sso.getIdToken(userId), - ).rejects.toMatchObject({ + platform.given.sso.tokens("another-user", { accessToken: "only-access" }); + await expect(base44.asServiceRole.sso.getIdToken("another-user")).rejects.toMatchObject({ name: "Base44Error", status: 404, code: "NOT_FOUND", From ccd9d79b50533ff07862ac591c0c6c4a9bc6ddf6 Mon Sep 17 00:00:00 2001 From: base44-os-gremlins Bot Date: Wed, 9 Sep 2026 15:42:07 +0000 Subject: [PATCH 6/9] test: enforce app-scoped platform behavior --- tests/README.md | 8 +- tests/mocks/platform/auth.ts | 361 +++++++++++------- tests/mocks/platform/entities.ts | 207 ++++++---- tests/mocks/platform/functions.ts | 22 +- tests/mocks/platform/index.ts | 211 +++++++--- tests/mocks/platform/state.ts | 65 ++-- tests/unit/analytics.test.ts | 32 +- tests/unit/auth-registration.test.ts | 78 +++- tests/unit/auth.test.js | 177 ++++++--- tests/unit/client.test.js | 56 +-- tests/unit/entities.test.ts | 116 ++++-- tests/unit/functions.test.ts | 129 ++++--- tests/unit/mock-platform-architecture.test.ts | 1 + 13 files changed, 984 insertions(+), 479 deletions(-) diff --git a/tests/README.md b/tests/README.md index bf34aced..d34e0d3f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -9,9 +9,9 @@ Arrange domain state with `platform.given`, act only through the SDK, then asser ```ts import { platform } from "./mocks/platform"; -platform.given.entities.records("Todo", [ - { id: "1", title: "Existing", completed: false }, -]); +platform.given + .app("test-app-id") + .entities.records("Todo", [{ id: "1", title: "Existing", completed: false }]); const created = await client.entities.Todo.create({ title: "Write a test", @@ -26,7 +26,7 @@ expect(platform.requests.last("entities.create").body).toEqual({ }); ``` -Use named fault fixtures such as `platform.given.faults.functions.notFound("missing")` for error cases. Common domain results belong in reusable fixtures; unique function or integration results may be supplied as domain outcomes, but tests must not choose HTTP statuses, headers, wire envelopes, or MSW resolvers. +Use app-scoped named fault fixtures such as `platform.given.app("test-app-id").faults.functions.notFound("missing")` for error cases. Register reusable domain behavior (for example `functions.notificationDelivery`) rather than supplying endpoint results. Tests must not choose HTTP statuses, headers, wire envelopes, or MSW resolvers. Global setup resets records, identities, deterministic identifiers, request journals and faults before and after every test. Initial handlers remain installed and `server.resetHandlers()` restores that same centralized set. Do not use concurrent tests against this singleton state. diff --git a/tests/mocks/platform/auth.ts b/tests/mocks/platform/auth.ts index b1a58daa..493a4385 100644 --- a/tests/mocks/platform/auth.ts +++ b/tests/mocks/platform/auth.ts @@ -1,13 +1,9 @@ import { delay, http, HttpResponse } from "msw"; import { recordRequest, state, type PlatformFault } from "./state"; -type User = Record & { id: string }; -type MeOutcome = - | { kind: "user"; user: User; delayMs?: number } - | { kind: "unauthorized"; delayMs?: number } - | { kind: "network-error"; delayMs?: number }; +export type User = Record & { id: string; email?: string }; -interface LoginAccount { +export interface LoginAccount { email: string; password: string; accessToken: string; @@ -15,57 +11,129 @@ interface LoginAccount { countryCode?: string; } -let currentUser: User | null = null; -let meOutcomes: MeOutcome[] = []; -let loginAccounts: LoginAccount[] = []; -let rejectedUpdates = 0; +interface Principal { + appId: string; + user: User; +} +interface ResetToken { + email: string; + expired: boolean; + consumed: boolean; +} + +const accounts = new Map>(); +const principals = new Map(); +const resetTokens = new Map>(); +const meLatencies = new Map(); +let rejectedUpdates = new Map(); let invalidLogins = new Set(); let unavailableLogins = new Set(); +let unavailableMe = new Set(); + +const scoped = (appId: string, value: string) => `${appId}\u0000${value}`; + +function accountStore(appId: string) { + let appAccounts = accounts.get(appId); + if (!appAccounts) { + appAccounts = new Map(); + accounts.set(appId, appAccounts); + } + return appAccounts; +} + +function resetTokenStore(appId: string) { + let appTokens = resetTokens.get(appId); + if (!appTokens) { + appTokens = new Map(); + resetTokens.set(appId, appTokens); + } + return appTokens; +} + +function bearerToken(request: Request) { + const authorization = request.headers.get("authorization"); + return authorization?.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : undefined; +} + +function principalFor(appId: string, request: Request) { + const token = bearerToken(request); + if (!token) return undefined; + const principal = principals.get(token); + return principal?.appId === appId ? { token, principal } : undefined; +} + +function unauthorized() { + return HttpResponse.json({ detail: "Unauthorized" }, { status: 401 }); +} export function resetAuthState() { - currentUser = null; - meOutcomes = []; - loginAccounts = []; - rejectedUpdates = 0; + accounts.clear(); + principals.clear(); + resetTokens.clear(); + meLatencies.clear(); + rejectedUpdates = new Map(); invalidLogins = new Set(); unavailableLogins = new Set(); + unavailableMe = new Set(); } -export const authFixtures = { - user(user: User) { - currentUser = structuredClone(user); - }, - meSequence(outcomes: ({ user: User } | { unauthorized: true } | { networkError: true })[], delayMs = 0) { - meOutcomes = outcomes.map((outcome) => { - if ("user" in outcome) - return { kind: "user", user: structuredClone(outcome.user), delayMs }; - if ("unauthorized" in outcome) - return { kind: "unauthorized", delayMs }; - return { kind: "network-error", delayMs }; - }); - }, - login(account: LoginAccount) { - loginAccounts.push(structuredClone(account)); - }, -}; +export function authFixturesFor(appId: string) { + return { + account(account: LoginAccount) { + const stored = structuredClone(account); + accountStore(appId).set(account.email, stored); + principals.set(account.accessToken, { appId, user: stored.user }); + }, + principal(token: string, user: User) { + const stored = structuredClone(user); + principals.set(token, { appId, user: stored }); + }, + meLatency(token: string, delayMs: number) { + meLatencies.set(scoped(appId, token), delayMs); + }, + passwordResetRequest(email: string, message = "Request accepted") { + state.passwordResetRequestMessages.set(scoped(appId, email), message); + }, + resetToken( + resetToken: string, + email: string, + options: { expired?: boolean } = {}, + ) { + resetTokenStore(appId).set(resetToken, { + email, + expired: options.expired ?? false, + consumed: false, + }); + }, + }; +} -export const authFaultFixtures = { - unauthorizedMe() { - meOutcomes.push({ kind: "unauthorized" }); - }, - networkUnavailableMe() { - meOutcomes.push({ kind: "network-error" }); - }, - rejectedUpdate() { - rejectedUpdates += 1; - }, - invalidCredentials(email: string) { - invalidLogins.add(email); - }, - networkUnavailableLogin(email: string) { - unavailableLogins.add(email); - }, -}; +export function authFaultFixturesFor(appId: string) { + return { + networkUnavailableMe(token: string) { + unavailableMe.add(scoped(appId, token)); + }, + rejectedUpdate(token: string) { + const key = scoped(appId, token); + rejectedUpdates.set(key, (rejectedUpdates.get(key) ?? 0) + 1); + }, + invalidCredentials(email: string) { + invalidLogins.add(scoped(appId, email)); + }, + networkUnavailableLogin(email: string) { + unavailableLogins.add(scoped(appId, email)); + }, + resetTokenExpired(resetToken: string) { + resetTokenStore(appId).set(resetToken, { + email: "", + expired: true, + consumed: false, + }); + }, + }; +} function takeFault(predicate: (fault: PlatformFault) => boolean) { const index = state.faults.findIndex(predicate); @@ -77,133 +145,136 @@ function takeFault(predicate: (fault: PlatformFault) => boolean) { export const authHandlers = [ http.get( "*/api/apps/:appId/entities/User/me", - async ({ request }) => { + async ({ params, request }) => { await recordRequest("auth.me", request); - const outcome = meOutcomes.shift(); - if (outcome?.delayMs) await delay(outcome.delayMs); - if (outcome?.kind === "network-error") return HttpResponse.error(); - if (outcome?.kind === "unauthorized" || (!outcome && !currentUser)) - return HttpResponse.json({ detail: "Unauthorized" }, { status: 401 }); - const user = outcome?.kind === "user" ? outcome.user : currentUser!; - currentUser = structuredClone(user); - return HttpResponse.json(user); + const appId = String(params.appId); + const resolved = principalFor(appId, request); + if (!resolved) return unauthorized(); + const key = scoped(appId, resolved.token); + const delayMs = meLatencies.get(key); + if (delayMs) await delay(delayMs); + if (unavailableMe.delete(key)) return HttpResponse.error(); + return HttpResponse.json(structuredClone(resolved.principal.user)); }, ), http.put( "*/api/apps/:appId/entities/User/me", - async ({ request }) => { + async ({ params, request }) => { await recordRequest("auth.updateMe", request); - if (rejectedUpdates > 0) { - rejectedUpdates -= 1; + const appId = String(params.appId); + const resolved = principalFor(appId, request); + if (!resolved) return unauthorized(); + const key = scoped(appId, resolved.token); + if ((rejectedUpdates.get(key) ?? 0) > 0) { + rejectedUpdates.set(key, rejectedUpdates.get(key)! - 1); return HttpResponse.json( { detail: "Invalid email format" }, { status: 400 }, ); } const updates = (await request.clone().json()) as Record; - currentUser = { ...(currentUser ?? { id: "user-1" }), ...updates } as User; - return HttpResponse.json(currentUser); + Object.assign(resolved.principal.user, updates); + return HttpResponse.json(structuredClone(resolved.principal.user)); }, ), - http.post( - "*/api/apps/:appId/auth/login", - async ({ request }) => { - await recordRequest("auth.login", request); - const body = (await request.clone().json()) as { - email: string; - password: string; - }; - if (unavailableLogins.delete(body.email)) return HttpResponse.error(); - if (invalidLogins.delete(body.email)) - return HttpResponse.json( - { detail: "Invalid credentials" }, - { status: 400 }, - ); - const account = loginAccounts.find( - (candidate) => - candidate.email === body.email && candidate.password === body.password, + http.post("*/api/apps/:appId/auth/login", async ({ params, request }) => { + await recordRequest("auth.login", request); + const appId = String(params.appId); + const body = (await request.clone().json()) as { + email: string; + password: string; + }; + const key = scoped(appId, body.email); + if (unavailableLogins.delete(key)) return HttpResponse.error(); + if (invalidLogins.delete(key)) + return HttpResponse.json( + { detail: "Invalid credentials" }, + { status: 400 }, ); - if (!account) - return HttpResponse.json( - { detail: "Invalid credentials" }, - { status: 400 }, - ); - currentUser = structuredClone(account.user); - return HttpResponse.json({ - access_token: account.accessToken, - country_code: account.countryCode ?? null, - success: true, - user: account.user, - }); - }, - ), - http.post( - "*/api/apps/:appId/auth/register", - async ({ request }) => { - await recordRequest("auth.register", request); - const body = (await request.clone().json()) as { email: string }; - if ( - takeFault( - (fault) => - fault.kind === "auth-registration-rejected" && - fault.email === body.email, - ) - ) { - return HttpResponse.json( - { detail: "Registration rejected" }, - { status: 400 }, - ); - } - const registration = state.registrations.get(body.email); - if (!registration) - return HttpResponse.json( - { detail: "Registration fixture not found" }, - { status: 404 }, - ); - return HttpResponse.json({ - id: registration.id, - message: registration.message, - otp_expires_in_minutes: registration.otpExpiresInMinutes, - country_code: registration.countryCode, - }); - }, - ), + const account = accountStore(appId).get(body.email); + if (!account || account.password !== body.password) + return HttpResponse.json( + { detail: "Invalid credentials" }, + { status: 400 }, + ); + principals.set(account.accessToken, { appId, user: account.user }); + return HttpResponse.json({ + access_token: account.accessToken, + country_code: account.countryCode ?? null, + success: true, + user: structuredClone(account.user), + }); + }), + http.post("*/api/apps/:appId/auth/register", async ({ params, request }) => { + await recordRequest("auth.register", request); + const appId = String(params.appId); + const body = (await request.clone().json()) as { email: string }; + if ( + takeFault( + (fault) => + fault.kind === "auth-registration-rejected" && + fault.appId === appId && + fault.email === body.email, + ) + ) + return HttpResponse.json( + { detail: "Registration rejected" }, + { status: 400 }, + ); + const registration = state.registrations.get(scoped(appId, body.email)); + if (!registration) + return HttpResponse.json( + { detail: "Registration fixture not found" }, + { status: 404 }, + ); + return HttpResponse.json({ + id: registration.id, + message: registration.message, + otp_expires_in_minutes: registration.otpExpiresInMinutes, + country_code: registration.countryCode, + }); + }), http.post( "*/api/apps/:appId/auth/reset-password-request", - async ({ request }) => { + async ({ params, request }) => { await recordRequest("auth.resetPasswordRequest", request); const body = (await request.clone().json()) as { email: string }; return HttpResponse.json({ message: - state.passwordResetRequestMessages.get(body.email) ?? - "Request accepted", + state.passwordResetRequestMessages.get( + scoped(String(params.appId), body.email), + ) ?? "Request accepted", }); }, ), http.post( "*/api/apps/:appId/auth/reset-password", - async ({ request }) => { + async ({ params, request }) => { await recordRequest("auth.resetPassword", request); - const body = (await request.clone().json()) as { reset_token: string }; - if ( - takeFault( - (fault) => - fault.kind === "auth-reset-token-expired" && - fault.resetToken === body.reset_token, - ) - ) { + const appId = String(params.appId); + const body = (await request.clone().json()) as { + reset_token: string; + new_password: string; + }; + const resetToken = resetTokenStore(appId).get(body.reset_token); + if (!resetToken || resetToken.expired || resetToken.consumed) return HttpResponse.json( - { detail: "Reset token expired" }, + { + detail: resetToken?.expired + ? "Reset token expired" + : "Reset token invalid", + }, { status: 400 }, ); - } - const user = state.passwordResetUsers.get(body.reset_token); - return user - ? HttpResponse.json(structuredClone(user)) - : HttpResponse.json( - { detail: "Reset token invalid" }, - { status: 400 }, - ); + const account = accountStore(appId).get(resetToken.email); + if (!account) + return HttpResponse.json( + { detail: "Reset token invalid" }, + { status: 400 }, + ); + account.password = body.new_password; + resetToken.consumed = true; + return HttpResponse.json(structuredClone(account.user)); }, ), ]; diff --git a/tests/mocks/platform/entities.ts b/tests/mocks/platform/entities.ts index ca7bc0f1..062f7338 100644 --- a/tests/mocks/platform/entities.ts +++ b/tests/mocks/platform/entities.ts @@ -13,25 +13,40 @@ function matches(record: PlatformRecord, query: Record): boolean { }); } -function collection(entityName: string) { - let records = state.entities.get(entityName); +function collection(appId: string, entityName: string) { + let appEntities = state.entities.get(appId); + if (!appEntities) { + appEntities = new Map(); + state.entities.set(appId, appEntities); + } + let records = appEntities.get(entityName); if (!records) { records = []; - state.entities.set(entityName, records); + appEntities.set(entityName, records); } return records; } +function nextId(appId: string) { + const id = state.nextEntityIds.get(appId) ?? 1; + state.nextEntityIds.set(appId, id + 1); + return String(id); +} + function select(records: PlatformRecord[], request: Request) { const search = new URL(request.url).searchParams; const query = search.get("q"); - let selected = query ? records.filter((item) => matches(item, JSON.parse(query))) : [...records]; + let selected = query + ? records.filter((item) => matches(item, JSON.parse(query))) + : [...records]; const sort = search.get("sort"); if (sort) { const descending = sort.startsWith("-"); const field = descending ? sort.slice(1) : sort; selected.sort((left, right) => { - const comparison = String(left[field]).localeCompare(String(right[field])); + const comparison = String(left[field]).localeCompare( + String(right[field]), + ); return descending ? -comparison : comparison; }); } @@ -49,72 +64,118 @@ function select(records: PlatformRecord[], request: Request) { } export const entityHandlers = [ - http.get("*/api/apps/:appId/entities/:entityName", async ({ params, request }) => { - await recordRequest("entities.list", request); - return HttpResponse.json(select(collection(String(params.entityName)), request)); - }), - http.post("*/api/apps/:appId/entities/:entityName", async ({ params, request }) => { - await recordRequest("entities.create", request); - const input = (await request.clone().json()) as Record; - const created = { id: String(state.nextEntityId++), ...input }; - collection(String(params.entityName)).push(created); - return HttpResponse.json(created, { status: 201 }); - }), - http.get("*/api/apps/:appId/entities/:entityName/:id", async ({ params, request }) => { - await recordRequest("entities.get", request); - const found = collection(String(params.entityName)).find( - (item) => item.id === params.id, - ); - return found - ? HttpResponse.json(found) - : HttpResponse.json({ detail: "Entity not found", code: "NOT_FOUND" }, { status: 404 }); - }), - http.put("*/api/apps/:appId/entities/:entityName/bulk", async ({ params, request }) => { - await recordRequest("entities.bulkUpdate", request); - const updates = (await request.clone().json()) as PlatformRecord[]; - const records = collection(String(params.entityName)); - const changed = updates.map((update) => { - const index = records.findIndex((item) => item.id === update.id); - if (index < 0) return update; - records[index] = { ...records[index], ...update }; - return records[index]; - }); - return HttpResponse.json(changed); - }), - http.put("*/api/apps/:appId/entities/:entityName/:id", async ({ params, request }) => { - await recordRequest("entities.update", request); - const updates = (await request.clone().json()) as Record; - const records = collection(String(params.entityName)); - const index = records.findIndex((item) => item.id === params.id); - if (index < 0) - return HttpResponse.json({ detail: "Entity not found", code: "NOT_FOUND" }, { status: 404 }); - records[index] = { ...records[index], ...updates }; - return HttpResponse.json(records[index]); - }), - http.delete("*/api/apps/:appId/entities/:entityName/:id", async ({ params, request }) => { - await recordRequest("entities.delete", request); - const records = collection(String(params.entityName)); - const index = records.findIndex((item) => item.id === params.id); - if (index >= 0) records.splice(index, 1); - return HttpResponse.json({ success: index >= 0 }); - }), - http.patch("*/api/apps/:appId/entities/:entityName/update-many", async ({ params, request }) => { - await recordRequest("entities.updateMany", request); - const { query, data } = (await request.clone().json()) as { - query: Record; - data: { $set?: Record; $inc?: Record }; - }; - const matching = collection(String(params.entityName)).filter((item) => matches(item, query)); - const selected = matching.slice(0, 500); - for (const item of selected) { - Object.assign(item, data.$set ?? {}); - for (const [field, amount] of Object.entries(data.$inc ?? {})) - item[field] = Number(item[field] ?? 0) + amount; - } - return HttpResponse.json({ - success: true, - updated: selected.length, - has_more: matching.length > selected.length, - }); - }), + http.get( + "*/api/apps/:appId/entities/:entityName", + async ({ params, request }) => { + await recordRequest("entities.list", request); + return HttpResponse.json( + select( + collection(String(params.appId), String(params.entityName)), + request, + ), + ); + }, + ), + http.post( + "*/api/apps/:appId/entities/:entityName", + async ({ params, request }) => { + await recordRequest("entities.create", request); + const input = (await request.clone().json()) as Record; + const appId = String(params.appId); + const created = { id: nextId(appId), ...input }; + collection(appId, String(params.entityName)).push(created); + return HttpResponse.json(created, { status: 201 }); + }, + ), + http.get( + "*/api/apps/:appId/entities/:entityName/:id", + async ({ params, request }) => { + await recordRequest("entities.get", request); + const found = collection( + String(params.appId), + String(params.entityName), + ).find((item) => item.id === params.id); + return found + ? HttpResponse.json(found) + : HttpResponse.json( + { detail: "Entity not found", code: "NOT_FOUND" }, + { status: 404 }, + ); + }, + ), + http.put( + "*/api/apps/:appId/entities/:entityName/bulk", + async ({ params, request }) => { + await recordRequest("entities.bulkUpdate", request); + const updates = (await request.clone().json()) as PlatformRecord[]; + const records = collection( + String(params.appId), + String(params.entityName), + ); + const changed = updates.map((update) => { + const index = records.findIndex((item) => item.id === update.id); + if (index < 0) return update; + records[index] = { ...records[index], ...update }; + return records[index]; + }); + return HttpResponse.json(changed); + }, + ), + http.put( + "*/api/apps/:appId/entities/:entityName/:id", + async ({ params, request }) => { + await recordRequest("entities.update", request); + const updates = (await request.clone().json()) as Record; + const records = collection( + String(params.appId), + String(params.entityName), + ); + const index = records.findIndex((item) => item.id === params.id); + if (index < 0) + return HttpResponse.json( + { detail: "Entity not found", code: "NOT_FOUND" }, + { status: 404 }, + ); + records[index] = { ...records[index], ...updates }; + return HttpResponse.json(records[index]); + }, + ), + http.delete( + "*/api/apps/:appId/entities/:entityName/:id", + async ({ params, request }) => { + await recordRequest("entities.delete", request); + const records = collection( + String(params.appId), + String(params.entityName), + ); + const index = records.findIndex((item) => item.id === params.id); + if (index >= 0) records.splice(index, 1); + return HttpResponse.json({ success: index >= 0 }); + }, + ), + http.patch( + "*/api/apps/:appId/entities/:entityName/update-many", + async ({ params, request }) => { + await recordRequest("entities.updateMany", request); + const { query, data } = (await request.clone().json()) as { + query: Record; + data: { $set?: Record; $inc?: Record }; + }; + const matching = collection( + String(params.appId), + String(params.entityName), + ).filter((item) => matches(item, query)); + const selected = matching.slice(0, 500); + for (const item of selected) { + Object.assign(item, data.$set ?? {}); + for (const [field, amount] of Object.entries(data.$inc ?? {})) + item[field] = Number(item[field] ?? 0) + amount; + } + return HttpResponse.json({ + success: true, + updated: selected.length, + has_more: matching.length > selected.length, + }); + }, + ), ]; diff --git a/tests/mocks/platform/functions.ts b/tests/mocks/platform/functions.ts index a0812df1..58b1c538 100644 --- a/tests/mocks/platform/functions.ts +++ b/tests/mocks/platform/functions.ts @@ -12,12 +12,14 @@ export const functionHandlers = [ http.post( "*/api/apps/:appId/functions/:functionName", async ({ params, request }) => { - await recordRequest("functions.invoke", request); + const recorded = await recordRequest("functions.invoke", request); + const appId = String(params.appId); const functionName = String(params.functionName); if ( takeFault( (fault) => fault.kind === "function-network-unavailable" && + fault.appId === appId && fault.functionName === functionName, ) ) { @@ -27,6 +29,7 @@ export const functionHandlers = [ takeFault( (fault) => fault.kind === "function-internal-error" && + fault.appId === appId && fault.functionName === functionName, ) ) { @@ -39,16 +42,25 @@ export const functionHandlers = [ takeFault( (fault) => fault.kind === "function-not-found" && + fault.appId === appId && fault.functionName === functionName, ) || - !state.functionResults.has(functionName) + !state.functionBehaviors.get(appId)?.has(functionName) ) { return HttpResponse.json( { error: "Function not found", code: "FUNCTION_NOT_FOUND" }, { status: 404 }, ); } - return HttpResponse.json(state.functionResults.get(functionName)); + const behavior = state.functionBehaviors.get(appId)!.get(functionName)!; + const result = await behavior({ + appId, + functionName, + body: recorded.body, + query: recorded.query, + headers: recorded.headers, + }); + return HttpResponse.json(result); }, ), // The SDK also exposes this legacy, non-app-scoped alias. It is not present @@ -62,12 +74,12 @@ export const functionHandlers = [ new URL(request.url).pathname.indexOf(marker) + marker.length, ), ); - if (!state.rawFunctions.has(path)) { + if (!state.legacyFunctions.has(path)) { return HttpResponse.json( { detail: "Function not found" }, { status: 404 }, ); } - return new HttpResponse(state.rawFunctions.get(path), { status: 200 }); + return new HttpResponse("ok", { status: 200 }); }), ]; diff --git a/tests/mocks/platform/index.ts b/tests/mocks/platform/index.ts index 667e13b9..9ee548a2 100644 --- a/tests/mocks/platform/index.ts +++ b/tests/mocks/platform/index.ts @@ -3,8 +3,8 @@ import { agentHandlers } from "./agents"; import { analyticsHandlers } from "./analytics"; import { appFixtures, appHandlers, resetAppState } from "./app"; import { - authFaultFixtures, - authFixtures, + authFaultFixturesFor, + authFixturesFor, authHandlers, resetAuthState, } from "./auth"; @@ -31,6 +31,8 @@ import { type PlatformRegistration, type PlatformRecord, type RecordedRequest, + type FunctionBehavior, + type MultipartBody, } from "./state"; function clone(value: T): T { @@ -60,84 +62,199 @@ function reset() { resetGenericState(); } -export const platform = { - reset, - given: { +function functionStore(appId: string) { + let functions = state.functionBehaviors.get(appId); + if (!functions) { + functions = new Map(); + state.functionBehaviors.set(appId, functions); + } + return functions; +} + +function registerFunction( + appId: string, + functionName: string, + behavior: FunctionBehavior, +) { + functionStore(appId).set(functionName, behavior); +} + +function multipart(body: unknown) { + return body as MultipartBody; +} + +function forApp(appId: string) { + const auth = authFixturesFor(appId); + const authFaults = authFaultFixturesFor(appId); + return { entities: { records(entityName: string, records: PlatformRecord[]) { - state.entities.set(entityName, clone(records)); + let appEntities = state.entities.get(appId); + if (!appEntities) { + appEntities = new Map(); + state.entities.set(appId, appEntities); + } + appEntities.set(entityName, clone(records)); const numericIds = records .map((record) => Number(record.id)) .filter(Number.isFinite); - state.nextEntityId = Math.max( - state.nextEntityId, - ...numericIds.map((id) => id + 1), - ); - }, - }, - agents: { - conversations(conversations: PlatformConversation[]) { - state.conversations = clone(conversations); - const numericIds = conversations - .map((conversation) => Number(conversation.id.match(/\d+$/)?.[0])) - .filter(Number.isFinite); - state.nextConversationId = Math.max( - state.nextConversationId, - ...numericIds.map((id) => id + 1), + state.nextEntityIds.set( + appId, + Math.max( + state.nextEntityIds.get(appId) ?? 1, + ...numericIds.map((id) => id + 1), + ), ); }, }, auth: { - ...authFixtures, + ...auth, registration(email: string, registration: PlatformRegistration) { - state.registrations.set(email, clone(registration)); - }, - passwordResetRequest(email: string, message = "Request accepted") { - state.passwordResetRequestMessages.set(email, message); - }, - passwordReset(resetToken: string, user: PlatformRecord) { - state.passwordResetUsers.set(resetToken, clone(user)); + state.registrations.set(`${appId}\u0000${email}`, clone(registration)); }, }, functions: { - result(functionName: string, result: unknown) { - state.functionResults.set(functionName, clone(result)); + notificationDelivery(nextMessageId: string) { + registerFunction(appId, "sendNotification", ({ body }) => ({ + success: true, + messageId: nextMessageId, + recipientId: (body as any).userId, + })); + }, + serviceHealth(timestamp: string) { + registerFunction(appId, "getStatus", () => ({ + status: "healthy", + timestamp, + })); + }, + userProcessor(functionName = "processData") { + registerFunction(appId, functionName, ({ body }) => ({ + processed: true, + userId: (body as any)?.user?.id, + })); + }, + fileStore(functionName: string, nextFileId: string) { + registerFunction(appId, functionName, ({ body }) => { + const file = multipart(body).entries.find( + (entry) => "file" in entry, + )?.file; + return { fileId: nextFileId, filename: file?.name, size: file?.size }; + }); + }, + documentProcessor(nextDocumentId: string, extractedText: string) { + registerFunction(appId, "processDocument", () => ({ + documentId: nextDocumentId, + processed: true, + extractedText, + })); + }, + formSubmissions(functionName: string, nextFormId: string) { + registerFunction(appId, functionName, () => ({ + formId: nextFormId, + submitted: true, + })); + }, + uploadAcceptance(functionName: string) { + registerFunction(appId, functionName, ({ body }) => ({ + ok: multipart(body).entries.some((entry) => "file" in entry), + success: true, + })); }, - raw(functionPath: string, response = "ok") { - state.rawFunctions.set(functionPath.replace(/^\//, ""), response); + inputReceipt(functionName: string) { + registerFunction(appId, functionName, ({ body }) => ({ + received: true, + values: body, + })); + }, + arrayProcessor(functionName: string) { + registerFunction(appId, functionName, ({ body }) => ({ + processed: true, + count: Object.values(body as Record).filter( + Array.isArray, + ).length, + })); + }, + authenticatedProbe(functionName: string) { + registerFunction(appId, functionName, ({ headers }) => ({ + success: true, + authenticated: headers.authorization?.startsWith("Bearer ") ?? false, + })); + }, + serviceExecution(functionName: string) { + registerFunction(appId, functionName, ({ body }) => ({ + result: + (body as any)?.param === "test" ? "function executed" : "ignored", + })); }, }, - integrations: integrationFixtures, - customIntegrations: customIntegrationFixtures, - connectors: connectorFixtures, - actors: actorFixtures, - app: appFixtures, - sso: ssoFixtures, - generic: genericFixtures, faults: { auth: { - ...authFaultFixtures, + ...authFaults, registrationRejected(email: string) { - state.faults.push({ kind: "auth-registration-rejected", email }); - }, - resetTokenExpired(resetToken: string) { - state.faults.push({ kind: "auth-reset-token-expired", resetToken }); + state.faults.push({ + kind: "auth-registration-rejected", + appId, + email, + }); }, }, functions: { internalError(functionName: string) { - state.faults.push({ kind: "function-internal-error", functionName }); + state.faults.push({ + kind: "function-internal-error", + appId, + functionName, + }); }, notFound(functionName: string) { - state.faults.push({ kind: "function-not-found", functionName }); + state.faults.push({ + kind: "function-not-found", + appId, + functionName, + }); }, networkUnavailable(functionName: string) { state.faults.push({ kind: "function-network-unavailable", + appId, functionName, }); }, }, + }, + }; +} + +const appGiven = Object.assign(forApp, appFixtures); + +export const platform = { + reset, + given: { + app: appGiven, + agents: { + conversations(conversations: PlatformConversation[]) { + state.conversations = clone(conversations); + const numericIds = conversations + .map((conversation) => Number(conversation.id.match(/\d+$/)?.[0])) + .filter(Number.isFinite); + state.nextConversationId = Math.max( + state.nextConversationId, + ...numericIds.map((id) => id + 1), + ); + }, + }, + functions: { + legacyEndpoint(functionPath: string) { + state.legacyFunctions.add(functionPath.replace(/^\//, "")); + }, + }, + integrations: integrationFixtures, + customIntegrations: customIntegrationFixtures, + connectors: connectorFixtures, + actors: actorFixtures, + sso: ssoFixtures, + generic: genericFixtures, + faults: { integrations: integrationFaultFixtures, customIntegrations: customIntegrationFaultFixtures, connectors: connectorFaultFixtures, diff --git a/tests/mocks/platform/state.ts b/tests/mocks/platform/state.ts index 8761bb64..7cfd6798 100644 --- a/tests/mocks/platform/state.ts +++ b/tests/mocks/platform/state.ts @@ -59,19 +59,38 @@ export interface ConnectorProxyOutcome { creditsCharged?: number; } +export interface FunctionInvocation { + appId: string; + functionName: string; + body: unknown; + query: Record; + headers: Record; +} + +export type FunctionBehavior = ( + invocation: FunctionInvocation, +) => unknown | Promise; + export type PlatformFault = - | { kind: "integration-invalid-parameters"; packageName: string; endpointName: string } + | { + kind: "integration-invalid-parameters"; + packageName: string; + endpointName: string; + } | { kind: "custom-upstream-unavailable"; slug: string; operationId: string } | { kind: "connector-credits-exhausted"; integrationType: string } | { kind: "metered-connector-token-refused"; integrationType: string } - | { kind: "auth-registration-rejected"; email: string } - | { kind: "auth-reset-token-expired"; resetToken: string } - | { kind: "function-internal-error"; functionName: string } - | { kind: "function-not-found"; functionName: string } - | { kind: "function-network-unavailable"; functionName: string }; + | { kind: "auth-registration-rejected"; appId: string; email: string } + | { kind: "function-internal-error"; appId: string; functionName: string } + | { kind: "function-not-found"; appId: string; functionName: string } + | { + kind: "function-network-unavailable"; + appId: string; + functionName: string; + }; interface PlatformState { - entities: Map; + entities: Map>; conversations: PlatformConversation[]; integrationEndpoints: Map; customIntegrations: Map>; @@ -81,11 +100,10 @@ interface PlatformState { connectorProxyOutcomes: Map; registrations: Map; passwordResetRequestMessages: Map; - passwordResetUsers: Map; - functionResults: Map; - rawFunctions: Map; + functionBehaviors: Map>; + legacyFunctions: Set; faults: PlatformFault[]; - nextEntityId: number; + nextEntityIds: Map; nextConversationId: number; nextMessageId: number; requests: RecordedRequest[]; @@ -102,11 +120,10 @@ export const state: PlatformState = { connectorProxyOutcomes: new Map(), registrations: new Map(), passwordResetRequestMessages: new Map(), - passwordResetUsers: new Map(), - functionResults: new Map(), - rawFunctions: new Map(), + functionBehaviors: new Map(), + legacyFunctions: new Set(), faults: [], - nextEntityId: 1, + nextEntityIds: new Map(), nextConversationId: 1, nextMessageId: 1, requests: [], @@ -123,17 +140,19 @@ export function resetPlatformState() { state.connectorProxyOutcomes.clear(); state.registrations.clear(); state.passwordResetRequestMessages.clear(); - state.passwordResetUsers.clear(); - state.functionResults.clear(); - state.rawFunctions.clear(); + state.functionBehaviors.clear(); + state.legacyFunctions.clear(); state.faults = []; - state.nextEntityId = 1; + state.nextEntityIds.clear(); state.nextConversationId = 1; state.nextMessageId = 1; state.requests = []; } -export async function recordRequest(route: string, request: Request) { +export async function recordRequest( + route: string, + request: Request, +): Promise { const url = new URL(request.url); let body: unknown; if (request.body) { @@ -163,12 +182,14 @@ export async function recordRequest(route: string, request: Request) { body = await request.clone().text(); } } - state.requests.push({ + const recorded = { route, method: request.method, url: request.url, query: Object.fromEntries(url.searchParams), headers: Object.fromEntries(request.headers), body, - }); + } satisfies RecordedRequest; + state.requests.push(recorded); + return recorded; } diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 9c2b9488..ff18fa42 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -22,7 +22,9 @@ describe("Analytics Module", () => { const serverUrl = "https://api.base44.com"; beforeEach(() => { - platform.given.auth.user({ id: "test-user-id" }); + platform.given + .app(appId) + .auth.principal("test-access-token", { id: "test-user-id" }); sharedState = getSharedInstance("analytics", () => ({ requestsQueue: [], isProcessing: false, @@ -53,7 +55,9 @@ describe("Analytics Module", () => { afterEach(async () => { // Let real intercepted requests and the processor finish before resetting shared state. - await vi.waitFor(() => expect(sharedState?.isProcessing).toBe(false), {timeout: 5000}); + await vi.waitFor(() => expect(sharedState?.isProcessing).toBe(false), { + timeout: 5000, + }); vi.useRealTimers(); vi.restoreAllMocks(); vi.clearAllMocks(); @@ -131,7 +135,7 @@ describe("Analytics Module", () => { vi.spyOn(base44.auth, "me").mockReturnValue( new Promise((resolve) => { resolveMe = resolve; - }) + }), ); // Flushing this event resolves the session context, which suspends on me(). @@ -210,27 +214,35 @@ describe("Analytics Module", () => { }); test("should track multiple events", async () => { - for (let i = 0; i < 5; i++) { base44.analytics.track({ eventName: `test-event ${i}` }); } expect(sharedState?.isProcessing).toBe(true); expect(sharedState?.requestsQueue.length).toBe(4); - await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(2), {timeout: 2500}); + await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(2), { + timeout: 2500, + }); // add another event while processing to mix things up base44.analytics.track({ eventName: `test-event 5` }); - await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(1), {timeout: 2500}); - await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(0), {timeout: 2500}); - await vi.waitFor(() => expect(sharedState?.isProcessing).toBe(false), {timeout: 2500}); + await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(1), { + timeout: 2500, + }); + await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(0), { + timeout: 2500, + }); + await vi.waitFor(() => expect(sharedState?.isProcessing).toBe(false), { + timeout: 2500, + }); const batches = platform.requests .all("analytics.trackBatch") .map((request) => request.body as { events: { event_name: string }[] }); expect(batches.length).toBeGreaterThan(0); expect(batches.every((batch) => batch.events.length <= 2)).toBe(true); - expect(batches.flatMap((batch) => batch.events.map((event) => event.event_name))) - .toEqual(Array.from({ length: 6 }, (_, index) => `test-event ${index}`)); + expect( + batches.flatMap((batch) => batch.events.map((event) => event.event_name)), + ).toEqual(Array.from({ length: 6 }, (_, index) => `test-event ${index}`)); }); }); diff --git a/tests/unit/auth-registration.test.ts b/tests/unit/auth-registration.test.ts index 971a283f..04f670df 100644 --- a/tests/unit/auth-registration.test.ts +++ b/tests/unit/auth-registration.test.ts @@ -17,7 +17,7 @@ describe("Auth registration and password recovery HTTP contracts", () => { turnstile_token: "challenge", referral_code: "referral", }; - platform.given.auth.registration(payload.email, { + platform.given.app(appId).auth.registration(payload.email, { id: "new-user-id", message: "Verification required", otpExpiresInMinutes: 10, @@ -36,7 +36,7 @@ describe("Auth registration and password recovery HTTP contracts", () => { email: "existing@example.test", password: "test-only-password", }; - platform.given.faults.auth.registrationRejected(payload.email); + platform.given.app(appId).faults.auth.registrationRejected(payload.email); await expect(client.auth.register(payload)).rejects.toMatchObject({ status: 400, message: "Registration rejected", @@ -44,20 +44,30 @@ describe("Auth registration and password recovery HTTP contracts", () => { expect(platform.requests.last("auth.register").body).toEqual(payload); }); test("password reset request sends only the email", async () => { - platform.given.auth.passwordResetRequest("reset@example.test"); + platform.given.app(appId).auth.passwordResetRequest("reset@example.test"); expect( await client.auth.resetPasswordRequest("reset@example.test"), ).toEqual({ message: "Request accepted" }); - expect( - platform.requests.last("auth.resetPasswordRequest").body, - ).toEqual({ email: "reset@example.test" }); - }); - test("password reset maps SDK camelCase to wire snake_case", async () => { - platform.given.auth.passwordReset("test-reset-token", { - id: "reset-user-id", + expect(platform.requests.last("auth.resetPasswordRequest").body).toEqual({ email: "reset@example.test", - name: "Reset User", }); + }); + test("password reset changes credentials, consumes the token, and supports subsequent SDK login", async () => { + const account = { + email: "reset@example.test", + password: "old-password", + accessToken: "reset-user-access-token", + user: { + id: "reset-user-id", + app_id: appId, + email: "reset@example.test", + name: "Reset User", + }, + }; + platform.given.app(appId).auth.account(account); + platform.given + .app(appId) + .auth.resetToken("test-reset-token", account.email); expect( await client.auth.resetPassword({ resetToken: "test-reset-token", @@ -65,6 +75,7 @@ describe("Auth registration and password recovery HTTP contracts", () => { }), ).toEqual({ id: "reset-user-id", + app_id: appId, email: "reset@example.test", name: "Reset User", }); @@ -72,9 +83,25 @@ describe("Auth registration and password recovery HTTP contracts", () => { reset_token: "test-reset-token", new_password: "test-new-password", }); + await expect( + client.auth.loginViaEmailPassword(account.email, "old-password"), + ).rejects.toMatchObject({ status: 400, message: "Invalid credentials" }); + await expect( + client.auth.loginViaEmailPassword(account.email, "test-new-password"), + ).resolves.toMatchObject({ + access_token: account.accessToken, + user: account.user, + }); + await expect(client.auth.me()).resolves.toEqual(account.user); + await expect( + client.auth.resetPassword({ + resetToken: "test-reset-token", + newPassword: "another-password", + }), + ).rejects.toMatchObject({ status: 400, message: "Reset token invalid" }); }); test("invalid reset token retains the error status and message", async () => { - platform.given.faults.auth.resetTokenExpired("expired"); + platform.given.app(appId).faults.auth.resetTokenExpired("expired"); await expect( client.auth.resetPassword({ resetToken: "expired", @@ -85,5 +112,32 @@ describe("Auth registration and password recovery HTTP contracts", () => { reset_token: "expired", new_password: "test-new-password", }); + await expect( + client.auth.resetPassword({ + resetToken: "unknown", + newPassword: "test-new-password", + }), + ).rejects.toMatchObject({ status: 400, message: "Reset token invalid" }); + }); + + test("does not accept a reset token issued for another app", async () => { + const otherAppId = "other-reset-app"; + const otherClient = createClient({ serverUrl, appId: otherAppId }); + platform.given.app(appId).auth.account({ + email: "scoped@example.test", + password: "old-password", + accessToken: "scoped-access-token", + user: { id: "scoped-user", app_id: appId, email: "scoped@example.test" }, + }); + platform.given + .app(appId) + .auth.resetToken("app-a-reset-token", "scoped@example.test"); + await expect( + otherClient.auth.resetPassword({ + resetToken: "app-a-reset-token", + newPassword: "new-password", + }), + ).rejects.toMatchObject({ status: 400, message: "Reset token invalid" }); + otherClient.cleanup(); }); }); diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 2c5e0761..26ffa8ba 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -16,6 +16,11 @@ describe("Auth Module", () => { const serverUrl = "https://api.base44.com"; const appBaseUrl = "https://api.base44.com"; + function authenticate(user, token = "test-access-token") { + platform.given.app(appId).auth.principal(token, user); + base44.auth.setToken(token, false); + } + beforeEach(() => { platform.reset(); // Mock window.addEventListener and document for analytics module @@ -57,7 +62,7 @@ describe("Auth Module", () => { role: "user", }; - platform.given.auth.user(mockUser); + authenticate(mockUser); // Call the API const result = await base44.auth.me(); @@ -69,16 +74,55 @@ describe("Auth Module", () => { }); test("preserves authentication error status", async () => { - platform.given.faults.auth.unauthorizedMe(); - // Call the API and expect an error await expect(base44.auth.me()).rejects.toMatchObject({ status: 401 }); }); + test("binds principals to bearer tokens and app scope without sharing login across clients", async () => { + const otherAppId = "other-app-id"; + const anonymousOther = createClient({ serverUrl, appId: otherAppId }); + const wrongScope = createClient({ + serverUrl, + appId: otherAppId, + token: "app-a-token", + }); + const appAAccount = { + email: "a@example.test", + password: "password-a", + accessToken: "app-a-token", + user: { id: "user-a", app_id: appId, email: "a@example.test" }, + }; + const appBAccount = { + email: "b@example.test", + password: "password-b", + accessToken: "app-b-token", + user: { id: "user-b", app_id: otherAppId, email: "b@example.test" }, + }; + platform.given.app(appId).auth.account(appAAccount); + platform.given.app(otherAppId).auth.account(appBAccount); + + await base44.auth.loginViaEmailPassword( + appAAccount.email, + appAAccount.password, + ); + await expect(base44.auth.me()).resolves.toEqual(appAAccount.user); + await expect(anonymousOther.auth.me()).rejects.toMatchObject({ + status: 401, + }); + await expect(wrongScope.auth.me()).rejects.toMatchObject({ status: 401 }); + + await anonymousOther.auth.loginViaEmailPassword( + appBAccount.email, + appBAccount.password, + ); + await expect(anonymousOther.auth.me()).resolves.toEqual(appBAccount.user); + await expect(base44.auth.me()).resolves.toEqual(appAAccount.user); + }); + test("shares one in-flight request between concurrent callers", async () => { const mockUser = { id: "user-123", email: "test@example.com" }; - platform.given.auth.user(mockUser); + authenticate(mockUser); const [first, second] = await Promise.all([ base44.auth.me(), @@ -91,12 +135,12 @@ describe("Auth Module", () => { }); test("does not reuse a resolved user across separate calls", async () => { - platform.given.auth.meSequence([ - { user: { id: "user-1" } }, - { user: { id: "user-2" } }, - ]); + authenticate({ id: "user-1" }); const first = await base44.auth.me(); + platform.given + .app(appId) + .auth.principal("test-access-token", { id: "user-2" }); const second = await base44.auth.me(); // Sharing is limited to the in-flight window; identity is never cached. @@ -106,23 +150,23 @@ describe("Auth Module", () => { test("does not retain a rejected request", async () => { const mockUser = { id: "user-123" }; - platform.given.auth.meSequence([ - { unauthorized: true }, - { user: mockUser }, - ]); + base44.auth.setToken("recovering-token", false); await expect(base44.auth.me()).rejects.toThrow(); + platform.given.app(appId).auth.principal("recovering-token", mockUser); await expect(base44.auth.me()).resolves.toEqual(mockUser); }); test("setToken() drops an in-flight request from the previous identity", async () => { - platform.given.auth.meSequence( - [ - { user: { id: "anonymous" } }, - { user: { id: "logged-in" } }, - ], - 50, - ); + platform.given + .app(appId) + .auth.principal("old-access-token", { id: "anonymous" }); + platform.given + .app(appId) + .auth.principal("new-access-token", { id: "logged-in" }); + platform.given.app(appId).auth.meLatency("old-access-token", 50); + platform.given.app(appId).auth.meLatency("new-access-token", 50); + base44.auth.setToken("old-access-token", false); const beforeLogin = base44.auth.me(); base44.auth.setToken("new-access-token", false); @@ -135,13 +179,15 @@ describe("Auth Module", () => { }); test("a superseded request does not retire the current one", async () => { - platform.given.auth.meSequence( - [ - { user: { id: "anonymous" } }, - { user: { id: "logged-in" } }, - ], - 50, - ); + platform.given + .app(appId) + .auth.principal("old-access-token", { id: "anonymous" }); + platform.given + .app(appId) + .auth.principal("new-access-token", { id: "logged-in" }); + platform.given.app(appId).auth.meLatency("old-access-token", 50); + platform.given.app(appId).auth.meLatency("new-access-token", 50); + base44.auth.setToken("old-access-token", false); const beforeLogin = base44.auth.me(); base44.auth.setToken("new-access-token", false); @@ -171,6 +217,29 @@ describe("Auth Module", () => { }); describe("updateMe()", () => { + test("rejects missing and invalid tokens without manufacturing a user", async () => { + const invalidClient = createClient({ + serverUrl, + appId, + token: "invalid-token", + }); + await expect( + base44.auth.updateMe({ name: "Anonymous" }), + ).rejects.toMatchObject({ + status: 401, + message: "Unauthorized", + }); + await expect( + invalidClient.auth.updateMe({ name: "Invalid" }), + ).rejects.toMatchObject({ + status: 401, + message: "Unauthorized", + }); + expect( + platform.requests.all("auth.updateMe").map((request) => request.body), + ).toEqual([{ name: "Anonymous" }, { name: "Invalid" }]); + }); + test("should update current user data", async () => { const updateData = { name: "Updated Name", @@ -183,7 +252,7 @@ describe("Auth Module", () => { role: "user", }; - platform.given.auth.user({ + authenticate({ id: "user-123", name: "Original Name", email: "original@example.com", @@ -205,8 +274,8 @@ describe("Auth Module", () => { email: "invalid-email", }; - platform.given.auth.user({ id: "user-123", email: "valid@example.com" }); - platform.given.faults.auth.rejectedUpdate(); + authenticate({ id: "user-123", email: "valid@example.com" }); + platform.given.app(appId).faults.auth.rejectedUpdate("test-access-token"); // Call the API and expect an error await expect(base44.auth.updateMe(invalidData)).rejects.toMatchObject({ @@ -327,13 +396,13 @@ describe("Auth Module", () => { describe("logout()", () => { test("should remove token from axios headers", async () => { - // Set a token first - base44.auth.setToken("test-token", false); - - platform.given.auth.user({ - id: "user-123", - email: "test@example.com", - }); + authenticate( + { + id: "user-123", + email: "test@example.com", + }, + "test-token", + ); // Verify token is set by making a request await base44.auth.me(); @@ -344,11 +413,11 @@ describe("Auth Module", () => { // Call logout base44.auth.logout(); - platform.given.faults.auth.unauthorizedMe(); - // Verify no Authorization header is sent after logout (should throw 401) await expect(base44.auth.me()).rejects.toThrow(); - expect(platform.requests.last("auth.me").headers.authorization).toBeUndefined(); + expect( + platform.requests.last("auth.me").headers.authorization, + ).toBeUndefined(); }); test("should remove token from localStorage in browser environment", async () => { @@ -464,7 +533,7 @@ describe("Auth Module", () => { base44.auth.setToken(token, false); - platform.given.auth.user({ + platform.given.app(appId).auth.principal(token, { id: "user-123", email: "test@example.com", }); @@ -528,11 +597,11 @@ describe("Auth Module", () => { test("should handle empty token gracefully", async () => { base44.auth.setToken("", false); - platform.given.faults.auth.unauthorizedMe(); - // Verify no Authorization header is sent (should throw 401) await expect(base44.auth.me()).rejects.toThrow(); - expect(platform.requests.last("auth.me").headers.authorization).toBeUndefined(); + expect( + platform.requests.last("auth.me").headers.authorization, + ).toBeUndefined(); }); test("should handle localStorage errors gracefully", () => { @@ -581,7 +650,7 @@ describe("Auth Module", () => { }, }; - platform.given.auth.login({ + platform.given.app(appId).auth.account({ email: loginData.email, password: loginData.password, accessToken: mockResponse.access_token, @@ -621,7 +690,7 @@ describe("Auth Module", () => { }, }; - platform.given.auth.login({ + platform.given.app(appId).auth.account({ email: loginData.email, password: loginData.password, accessToken: mockResponse.access_token, @@ -652,7 +721,7 @@ describe("Auth Module", () => { password: "wrongpassword", }; - platform.given.faults.auth.invalidCredentials(loginData.email); + platform.given.app(appId).faults.auth.invalidCredentials(loginData.email); // Set a token first to test logout base44.auth.setToken("existing-token", false); @@ -673,7 +742,9 @@ describe("Auth Module", () => { password: "password123", }; - platform.given.faults.auth.networkUnavailableLogin(loginData.email); + platform.given + .app(appId) + .faults.auth.networkUnavailableLogin(loginData.email); // Call the API and expect an error await expect( @@ -690,7 +761,7 @@ describe("Auth Module", () => { email: "test@example.com", }; - platform.given.auth.user(mockUser); + authenticate(mockUser); // Call the API const result = await base44.auth.isAuthenticated(); @@ -700,8 +771,6 @@ describe("Auth Module", () => { }); test("should return false when token is invalid", async () => { - platform.given.faults.auth.unauthorizedMe(); - // Call the API const result = await base44.auth.isAuthenticated(); @@ -710,7 +779,13 @@ describe("Auth Module", () => { }); test("should return false on network errors", async () => { - platform.given.faults.auth.networkUnavailableMe(); + base44.auth.setToken("network-token", false); + platform.given + .app(appId) + .auth.principal("network-token", { id: "user-123" }); + platform.given + .app(appId) + .faults.auth.networkUnavailableMe("network-token"); // Call the API const result = await base44.auth.isAuthenticated(); diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 9ec3f0da..efc1e926 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -355,7 +355,7 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - platform.given.entities.records("Todo", []); + platform.given.app(appId).entities.records("Todo", []); // Make requests await client.entities.Todo.list(); @@ -376,9 +376,9 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - platform.given.entities.records("User", [ - { id: "123", name: "Test User" }, - ]); + platform.given + .app(appId) + .entities.records("User", [{ id: "123", name: "Test User" }]); // Make request const result = await client.asServiceRole.entities.User.get("123"); @@ -412,9 +412,9 @@ describe("Service Role Authorization Headers", () => { // Verify response expect(result.success).toBe(true); expect(result.messageId).toBe("123"); - expect(platform.requests.last("integrations.invoke").headers.authorization).toBe( - `Bearer ${serviceToken}`, - ); + expect( + platform.requests.last("integrations.invoke").headers.authorization, + ).toBe(`Bearer ${serviceToken}`); }); test("should use service token for service role functions operations", async () => { @@ -426,9 +426,7 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - platform.given.functions.result("testFunction", { - result: "function executed", - }); + platform.given.app(appId).functions.serviceExecution("testFunction"); // Make request const result = await client.asServiceRole.functions.invoke("testFunction", { @@ -454,9 +452,9 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - platform.given.entities.records("Task", [ - { id: "task1", title: "User Task" }, - ]); + platform.given + .app(appId) + .entities.records("Task", [{ id: "task1", title: "User Task" }]); platform.given.integrations.emailDelivered("email123"); // Make requests using regular client (not service role) @@ -474,9 +472,9 @@ describe("Service Role Authorization Headers", () => { expect(platform.requests.last("entities.list").headers.authorization).toBe( `Bearer ${userToken}`, ); - expect(platform.requests.last("integrations.invoke").headers.authorization).toBe( - `Bearer ${userToken}`, - ); + expect( + platform.requests.last("integrations.invoke").headers.authorization, + ).toBe(`Bearer ${userToken}`); }); test("should work without authorization header when no tokens are provided", async () => { @@ -485,16 +483,18 @@ describe("Service Role Authorization Headers", () => { appId, }); - platform.given.entities.records("PublicData", [ - { id: "public1", data: "public" }, - ]); + platform.given + .app(appId) + .entities.records("PublicData", [{ id: "public1", data: "public" }]); // Make request const result = await client.entities.PublicData.list(); // Verify response expect(result[0].data).toBe("public"); - expect(platform.requests.last("entities.list").headers.authorization).toBeUndefined(); + expect( + platform.requests.last("entities.list").headers.authorization, + ).toBeUndefined(); }); test("should propagate Base44-State header in API requests when created from request", async () => { @@ -516,7 +516,7 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - platform.given.entities.records("Todo", []); + platform.given.app(appId).entities.records("Todo", []); // Make request await client.entities.Todo.list(); @@ -543,7 +543,7 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - platform.given.entities.records("Todo", []); + platform.given.app(appId).entities.records("Todo", []); await client.entities.Todo.list(); expect(platform.requests.last("entities.list").headers).toMatchObject({ @@ -569,7 +569,7 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - platform.given.entities.records("Todo", []); + platform.given.app(appId).entities.records("Todo", []); await client.entities.Todo.list(); const headers = platform.requests.last("entities.list").headers; @@ -593,7 +593,7 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - platform.given.entities.records("Todo", []); + platform.given.app(appId).entities.records("Todo", []); await client.entities.Todo.list(); const headers = platform.requests.last("entities.list").headers; @@ -617,7 +617,7 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - platform.given.entities.records("Todo", []); + platform.given.app(appId).entities.records("Todo", []); // Make request await client.entities.Todo.list(); @@ -645,9 +645,9 @@ describe("Service Role Authorization Headers", () => { const client = createClientFromRequest(mockRequest); - platform.given.entities.records("User", [ - { id: "123", name: "Test User" }, - ]); + platform.given + .app(appId) + .entities.records("User", [{ id: "123", name: "Test User" }]); // Make request using service role const result = await client.asServiceRole.entities.User.get("123"); diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts index 6187ddc6..8f27f40c 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -18,19 +18,20 @@ declare module "../../src/modules/entities.types.ts" { describe("Entities Module", () => { let base44: ReturnType; + const appId = "test-app-id"; beforeEach(() => { platform.reset(); base44 = createClient({ serverUrl: "https://api.base44.com", - appId: "test-app-id", + appId, }); }); afterEach(() => base44.cleanup()); test("list() fetches arranged entities with the correct parameters", async () => { - platform.given.entities.records("Todo", [ + platform.given.app(appId).entities.records("Todo", [ { id: "1", title: "Task 1", completed: false }, { id: "2", title: "Task 2", completed: true }, ]); @@ -52,9 +53,11 @@ describe("Entities Module", () => { }); test("list() retains id when a field projection omits it", async () => { - platform.given.entities.records("Todo", [ - { id: "1", title: "Projected", completed: false }, - ]); + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "1", title: "Projected", completed: false }, + ]); await expect( base44.entities.Todo.list(undefined, undefined, undefined, ["title"]), @@ -62,7 +65,7 @@ describe("Entities Module", () => { }); test("filter() sends the query and returns matching domain state", async () => { - platform.given.entities.records("Todo", [ + platform.given.app(appId).entities.records("Todo", [ { id: "1", title: "Task 1", completed: false }, { id: "2", title: "Task 2", completed: true }, ]); @@ -70,13 +73,15 @@ describe("Entities Module", () => { const result = await base44.entities.Todo.filter({ completed: true }); expect(result).toEqual([{ id: "2", title: "Task 2", completed: true }]); - expect(JSON.parse(platform.requests.last("entities.list").query.q)).toEqual({ - completed: true, - }); + expect(JSON.parse(platform.requests.last("entities.list").query.q)).toEqual( + { + completed: true, + }, + ); }); test("filter() supports typed advanced query syntax", async () => { - platform.given.entities.records("Todo", [ + platform.given.app(appId).entities.records("Todo", [ { id: "1", title: "Task 1", completed: false, description: "notes" }, { id: "2", title: "Task 2", completed: true, description: null }, ]); @@ -90,13 +95,17 @@ describe("Entities Module", () => { expect(result).toHaveLength(1); expect(result[0].id).toBe("2"); - expect(JSON.parse(platform.requests.last("entities.list").query.q)).toEqual(query); + expect(JSON.parse(platform.requests.last("entities.list").query.q)).toEqual( + query, + ); }); test("get() fetches one arranged entity", async () => { - platform.given.entities.records("Todo", [ - { id: "123", title: "Get milk", completed: false }, - ]); + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "123", title: "Get milk", completed: false }, + ]); await expect(base44.entities.Todo.get("123")).resolves.toEqual({ id: "123", @@ -106,7 +115,7 @@ describe("Entities Module", () => { }); test("create() persists so subsequent get() and list() observe the record", async () => { - platform.given.entities.records("Todo", []); + platform.given.app(appId).entities.records("Todo", []); const created = await base44.entities.Todo.create({ title: "New task", @@ -114,7 +123,9 @@ describe("Entities Module", () => { }); expect(created).toEqual({ id: "1", title: "New task", completed: false }); - await expect(base44.entities.Todo.get(created.id)).resolves.toEqual(created); + await expect(base44.entities.Todo.get(created.id)).resolves.toEqual( + created, + ); await expect(base44.entities.Todo.list()).resolves.toContainEqual(created); expect(platform.requests.last("entities.create").body).toEqual({ title: "New task", @@ -122,17 +133,50 @@ describe("Entities Module", () => { }); }); - test("update() changes the stored entity", async () => { - platform.given.entities.records("Todo", [ - { id: "123", title: "Old task", completed: false }, + test("isolates entity state and generated identifiers by application", async () => { + const otherAppId = "other-entity-app"; + const otherClient = createClient({ + serverUrl: "https://api.base44.com", + appId: otherAppId, + }); + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "7", title: "App A", completed: false }, + ]); + platform.given + .app(otherAppId) + .entities.records("Todo", [{ id: "7", title: "App B", completed: true }]); + + const createdA = await base44.entities.Todo.create({ + title: "Only A", + completed: false, + }); + expect(createdA.id).toBe("8"); + await expect(base44.entities.Todo.list()).resolves.toHaveLength(2); + await expect(otherClient.entities.Todo.list()).resolves.toEqual([ + { id: "7", title: "App B", completed: true }, ]); + otherClient.cleanup(); + }); + + test("update() changes the stored entity", async () => { + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "123", title: "Old task", completed: false }, + ]); const updated = await base44.entities.Todo.update("123", { title: "Updated task", completed: true, }); - expect(updated).toEqual({ id: "123", title: "Updated task", completed: true }); + expect(updated).toEqual({ + id: "123", + title: "Updated task", + completed: true, + }); await expect(base44.entities.Todo.get("123")).resolves.toEqual(updated); expect(platform.requests.last("entities.update").body).toEqual({ title: "Updated task", @@ -141,16 +185,20 @@ describe("Entities Module", () => { }); test("delete() removes the stored entity and returns DeleteResult", async () => { - platform.given.entities.records("Todo", [ - { id: "123", title: "Delete me", completed: false }, - ]); - - await expect(base44.entities.Todo.delete("123")).resolves.toEqual({ success: true }); + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "123", title: "Delete me", completed: false }, + ]); + + await expect(base44.entities.Todo.delete("123")).resolves.toEqual({ + success: true, + }); await expect(base44.entities.Todo.list()).resolves.toEqual([]); }); test("updateMany() applies update operators to matching records", async () => { - platform.given.entities.records("Todo", [ + platform.given.app(appId).entities.records("Todo", [ { id: "1", title: "One", completed: false }, { id: "2", title: "Two", completed: false }, { id: "3", title: "Three", completed: false }, @@ -163,7 +211,9 @@ describe("Entities Module", () => { ); expect(result).toEqual({ success: true, updated: 3, has_more: false }); - expect(await base44.entities.Todo.filter({ completed: true })).toHaveLength(4); + expect(await base44.entities.Todo.filter({ completed: true })).toHaveLength( + 4, + ); expect(platform.requests.last("entities.updateMany").body).toEqual({ query: { completed: false }, data: { $set: { completed: true } }, @@ -171,7 +221,7 @@ describe("Entities Module", () => { }); test("updateMany() reports has_more at the platform batch limit", async () => { - platform.given.entities.records( + platform.given.app(appId).entities.records( "Todo", Array.from({ length: 501 }, (_, index) => ({ id: String(index + 1), @@ -192,7 +242,7 @@ describe("Entities Module", () => { }); test("bulkUpdate() updates records without dropping existing fields", async () => { - platform.given.entities.records("Todo", [ + platform.given.app(appId).entities.records("Todo", [ { id: "1", title: "Task 1", completed: false }, { id: "2", title: "Task 2", completed: false }, ]); @@ -211,9 +261,11 @@ describe("Entities Module", () => { }); test("reset() isolates platform state and request history", async () => { - platform.given.entities.records("Todo", [ - { id: "1", title: "Transient", completed: false }, - ]); + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "1", title: "Transient", completed: false }, + ]); await base44.entities.Todo.list(); platform.reset(); diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index 059287b5..62de6b7c 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -36,10 +36,7 @@ describe("Functions Module", () => { priority: "high", }; - platform.given.functions.result(functionName, { - success: true, - messageId: "msg-456", - }); + platform.given.app(appId).functions.notificationDelivery("msg-456"); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -50,15 +47,16 @@ describe("Functions Module", () => { expect(platform.requests.last("functions.invoke").body).toEqual( functionData, ); + expect(platform.requests.last("functions.invoke").headers).toMatchObject({ + "content-type": "application/json", + "x-app-id": appId, + }); }); test("should handle function with empty object parameters", async () => { const functionName = "getStatus"; - platform.given.functions.result(functionName, { - status: "healthy", - timestamp: "2024-01-01T00:00:00Z", - }); + platform.given.app(appId).functions.serviceHealth("2024-01-01T00:00:00Z"); // Call the function const result = await base44.functions.invoke(functionName, {}); @@ -87,10 +85,7 @@ describe("Functions Module", () => { }, }; - platform.given.functions.result(functionName, { - processed: true, - userId: "123", - }); + platform.given.app(appId).functions.userProcessor(); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -111,11 +106,7 @@ describe("Functions Module", () => { category: "documents", }; - platform.given.functions.result(functionName, { - fileId: "file-789", - filename: "test.txt", - size: 12, - }); + platform.given.app(appId).functions.fileStore(functionName, "file-789"); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -151,11 +142,9 @@ describe("Functions Module", () => { priority: "high", }; - platform.given.functions.result(functionName, { - documentId: "doc-123", - processed: true, - extractedText: "document content", - }); + platform.given + .app(appId) + .functions.documentProcessor("doc-123", "document content"); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -189,10 +178,9 @@ describe("Functions Module", () => { formData.append("email", "john@example.com"); formData.append("message", "Hello there"); - platform.given.functions.result(functionName, { - formId: "form-456", - submitted: true, - }); + platform.given + .app(appId) + .functions.formSubmissions(functionName, "form-456"); // Call the function const result = await base44.functions.invoke(functionName, formData); @@ -221,9 +209,10 @@ describe("Functions Module", () => { type: "application/octet-stream", }), ); - platform.given.functions.result("upload", { ok: true }); + platform.given.app(appId).functions.uploadAcceptance("upload"); expect((await base44.functions.invoke("upload", form)).data).toEqual({ ok: true, + success: true, }); expect(form.getAll("tag")).toEqual(["one", "two"]); expect( @@ -243,6 +232,9 @@ describe("Functions Module", () => { }, }, ]); + expect( + platform.requests.last("functions.invoke").headers["content-type"], + ).toMatch(/^multipart\/form-data; boundary=/); }); test("should throw error for string input instead of object", async () => { @@ -263,7 +255,7 @@ describe("Functions Module", () => { input: "test data", }; - platform.given.functions.result(functionName, { processed: true }); + platform.given.app(appId).functions.userProcessor(functionName); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -281,15 +273,25 @@ describe("Functions Module", () => { param: "value", }; - platform.given.faults.functions.internalError(functionName); + platform.given.app(appId).faults.functions.internalError(functionName); + platform.given.app(appId).functions.userProcessor(functionName); // Call the function and expect it to throw await expect( base44.functions.invoke(functionName, functionData), - ).rejects.toThrow(); + ).rejects.toMatchObject({ + message: "Request failed with status code 500", + response: { + status: 500, + data: { error: "Internal server error", code: "INTERNAL_ERROR" }, + }, + }); expect(platform.requests.last("functions.invoke").body).toEqual( functionData, ); + await expect( + base44.functions.invoke(functionName, functionData), + ).resolves.toMatchObject({ data: { processed: true } }); }); test("should handle 404 errors for non-existent functions", async () => { @@ -298,12 +300,18 @@ describe("Functions Module", () => { param: "value", }; - platform.given.faults.functions.notFound(functionName); + platform.given.app(appId).faults.functions.notFound(functionName); // Call the function and expect it to throw await expect( base44.functions.invoke(functionName, functionData), - ).rejects.toThrow(); + ).rejects.toMatchObject({ + message: "Request failed with status code 404", + response: { + status: 404, + data: { error: "Function not found", code: "FUNCTION_NOT_FOUND" }, + }, + }); expect(platform.requests.last("functions.invoke").body).toEqual( functionData, ); @@ -318,10 +326,7 @@ describe("Functions Module", () => { emptyString: "", }; - platform.given.functions.result(functionName, { - received: true, - values: functionData, - }); + platform.given.app(appId).functions.inputReceipt(functionName); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -343,10 +348,7 @@ describe("Functions Module", () => { mixed: [1, "two", { three: 3 }], }; - platform.given.functions.result(functionName, { - processed: true, - count: 3, - }); + platform.given.app(appId).functions.arrayProcessor(functionName); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -368,7 +370,7 @@ describe("Functions Module", () => { category: "documents", }; - platform.given.functions.result(functionName, { success: true }); + platform.given.app(appId).functions.uploadAcceptance(functionName); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -387,7 +389,7 @@ describe("Functions Module", () => { formData.append("name", "John Doe"); formData.append("email", "john@example.com"); - platform.given.functions.result(functionName, { success: true }); + platform.given.app(appId).functions.uploadAcceptance(functionName); // Call the function const result = await base44.functions.invoke(functionName, formData); @@ -417,10 +419,7 @@ describe("Functions Module", () => { token: userToken, }); - platform.given.functions.result(functionName, { - success: true, - authenticated: true, - }); + platform.given.app(appId).functions.authenticatedProbe(functionName); // Call the function const result = await authenticatedBase44.functions.invoke( @@ -437,8 +436,38 @@ describe("Functions Module", () => { authenticatedBase44.cleanup(); }); + test("dispatches the same function name to app-scoped registered behavior", async () => { + const otherAppId = "other-function-app"; + const thirdAppId = "unconfigured-function-app"; + const otherClient = createClient({ serverUrl, appId: otherAppId }); + const unconfiguredClient = createClient({ serverUrl, appId: thirdAppId }); + platform.given.app(appId).functions.serviceHealth("app-a-time"); + platform.given.app(otherAppId).functions.serviceHealth("app-b-time"); + + await expect( + base44.functions.invoke("getStatus", {}), + ).resolves.toMatchObject({ + data: { status: "healthy", timestamp: "app-a-time" }, + }); + await expect( + otherClient.functions.invoke("getStatus", {}), + ).resolves.toMatchObject({ + data: { status: "healthy", timestamp: "app-b-time" }, + }); + await expect( + unconfiguredClient.functions.invoke("getStatus", {}), + ).rejects.toMatchObject({ + response: { + status: 404, + data: { error: "Function not found", code: "FUNCTION_NOT_FOUND" }, + }, + }); + otherClient.cleanup(); + unconfiguredClient.cleanup(); + }); + test("should fetch function endpoint directly", async () => { - platform.given.functions.raw("my_function"); + platform.given.functions.legacyEndpoint("my_function"); await base44.functions.fetch("/my_function", { method: "GET" }); @@ -455,7 +484,7 @@ describe("Functions Module", () => { token: userToken, }); - platform.given.functions.raw("streaming_demo"); + platform.given.functions.legacyEndpoint("streaming_demo"); await authenticatedBase44.functions.fetch("streaming_demo", { method: "POST", @@ -470,7 +499,7 @@ describe("Functions Module", () => { }); test("should normalize path with and without leading slash", async () => { - platform.given.functions.raw("my_function"); + platform.given.functions.legacyEndpoint("my_function"); await base44.functions.fetch("/my_function"); await base44.functions.fetch("my_function"); @@ -488,7 +517,7 @@ describe("Functions Module", () => { const serviceToken = "service-role-token"; const serviceRoleBase44 = createClient({ serverUrl, appId, serviceToken }); - platform.given.functions.raw("service_function"); + platform.given.functions.legacyEndpoint("service_function"); await serviceRoleBase44.asServiceRole.functions.fetch("/service_function", { method: "GET", diff --git a/tests/unit/mock-platform-architecture.test.ts b/tests/unit/mock-platform-architecture.test.ts index d6a80868..e872a770 100644 --- a/tests/unit/mock-platform-architecture.test.ts +++ b/tests/unit/mock-platform-architecture.test.ts @@ -13,6 +13,7 @@ describe("mock platform architecture", () => { ["from ", '"msw"'].join(""), ["from ", "'msw'"].join(""), ["mocks/", "server"].join(""), + ["given.functions", ".result("].join(""), ]; const violations = readdirSync(unitDirectory) .filter((name) => /\.test\.[jt]s$/.test(name)) From 3f220f77421f16fa5a23d2d33f8bd0dad216c778 Mon Sep 17 00:00:00 2001 From: base44-os-gremlins Bot Date: Wed, 9 Sep 2026 15:44:21 +0000 Subject: [PATCH 7/9] test: prove same-app client auth isolation --- tests/unit/auth.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 26ffa8ba..74125886 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -80,6 +80,12 @@ describe("Auth Module", () => { test("binds principals to bearer tokens and app scope without sharing login across clients", async () => { const otherAppId = "other-app-id"; + const anonymousSameApp = createClient({ serverUrl, appId }); + const invalidSameApp = createClient({ + serverUrl, + appId, + token: "unknown-token", + }); const anonymousOther = createClient({ serverUrl, appId: otherAppId }); const wrongScope = createClient({ serverUrl, @@ -106,6 +112,18 @@ describe("Auth Module", () => { appAAccount.password, ); await expect(base44.auth.me()).resolves.toEqual(appAAccount.user); + await expect(anonymousSameApp.auth.me()).rejects.toMatchObject({ + status: 401, + }); + expect( + platform.requests.last("auth.me").headers.authorization, + ).toBeUndefined(); + await expect(invalidSameApp.auth.me()).rejects.toMatchObject({ + status: 401, + }); + expect(platform.requests.last("auth.me").headers.authorization).toBe( + "Bearer unknown-token", + ); await expect(anonymousOther.auth.me()).rejects.toMatchObject({ status: 401, }); From d64bec2ee1c1a5d1752f9d480e6f945d4a14bfde Mon Sep 17 00:00:00 2001 From: base44-os-gremlins Bot Date: Wed, 9 Sep 2026 16:52:41 +0000 Subject: [PATCH 8/9] test: enforce scoped platform contracts --- tests/mocks/platform/agents.ts | 144 +++++++--- tests/mocks/platform/auth.ts | 31 ++- tests/mocks/platform/connectors.ts | 359 ++++++++++++++++++++----- tests/mocks/platform/entities.ts | 17 +- tests/mocks/platform/functions.ts | 2 +- tests/mocks/platform/index.ts | 87 ++++-- tests/mocks/platform/integrations.ts | 249 +++++++++++------ tests/mocks/platform/state.ts | 53 +++- tests/unit/agents.test.ts | 161 +++++++++-- tests/unit/client.test.js | 4 +- tests/unit/connectors-proxy.test.ts | 236 ++++++++++++---- tests/unit/connectors.test.ts | 347 +++++++++++++++++++++--- tests/unit/custom-integrations.test.ts | 216 +++++++++++---- tests/unit/entities.test.ts | 53 ++++ tests/unit/integrations.test.js | 82 ++++-- tests/unit/integrations.test.ts | 30 ++- 16 files changed, 1666 insertions(+), 405 deletions(-) diff --git a/tests/mocks/platform/agents.ts b/tests/mocks/platform/agents.ts index 954f0cc3..dfecc44f 100644 --- a/tests/mocks/platform/agents.ts +++ b/tests/mocks/platform/agents.ts @@ -1,47 +1,125 @@ import { http, HttpResponse } from "msw"; import { recordRequest, state, type PlatformConversation } from "./state"; +import { principalFor } from "./auth"; -// Pinned Apper d9ae151 enforces caller ownership/visitor rules, strips -// reserved metadata, and redacts public conversations. This in-memory model -// intentionally covers SDK-visible conversation transitions only; it does not -// claim to reproduce that authorization and redaction policy. -export const agentHandlers = [ - http.get("*/api/apps/:appId/agents/conversations", async ({ request }) => { - await recordRequest("agents.listConversations", request); - return HttpResponse.json(state.conversations); - }), - http.get("*/api/apps/:appId/agents/conversations/:conversationId", async ({ params, request }) => { - await recordRequest("agents.getConversation", request); - const conversation = state.conversations.find( - (item) => item.id === params.conversationId, - ); - return conversation - ? HttpResponse.json(conversation) - : HttpResponse.json({ detail: "Conversation not found", code: "NOT_FOUND" }, { status: 404 }); - }), - http.post("*/api/apps/:appId/agents/conversations", async ({ request }) => { - await recordRequest("agents.createConversation", request); - const input = (await request.clone().json()) as Pick; - const conversation: PlatformConversation = { - id: `conv-${state.nextConversationId++}`, - agent_name: input.agent_name, - messages: [], +function identity(appId: string, request: Request) { + const principal = principalFor(appId, request)?.principal; + if (principal) return { kind: "user" as const, id: principal.user.id }; + const visitorId = request.headers.get("x-base44-anonymous-id"); + return visitorId ? { kind: "visitor" as const, id: visitorId } : undefined; +} + +function forbidden(message: string) { + return HttpResponse.json({ detail: message }, { status: 403 }); +} + +function authorizedConversation( + appId: string, + request: Request, + conversationId: string, +) { + const stored = state.conversations.get(conversationId); + if (!stored) + return { + response: HttpResponse.json( + { detail: "Conversation not found", code: "NOT_FOUND" }, + { status: 404 }, + ), + }; + if (stored.appId !== appId) + return { + response: forbidden("Access denied: conversation belongs to another app"), + }; + const caller = identity(appId, request); + if ( + !caller || + caller.kind !== stored.owner.kind || + caller.id !== stored.owner.id + ) + return { + response: forbidden( + `Access denied: conversation belongs to another ${stored.owner.kind}`, + ), }; - state.conversations.push(conversation); - return HttpResponse.json(conversation); - }), + return { stored }; +} + +// Pinned Apper d9ae151 app/owner/visitor authorization is modeled here. +// Reserved metadata stripping, public redaction, internal/room filtering and +// actual LLM generation remain explicit unsupported policy boundaries. +export const agentHandlers = [ + http.get( + "*/api/apps/:appId/agents/conversations", + async ({ params, request }) => { + await recordRequest("agents.listConversations", request); + const appId = String(params.appId); + const caller = identity(appId, request); + if (!caller) return HttpResponse.json([]); + return HttpResponse.json( + [...state.conversations.values()] + .filter( + (item) => + item.appId === appId && + item.owner.kind === caller.kind && + item.owner.id === caller.id, + ) + .map((item) => item.record), + ); + }, + ), + http.get( + "*/api/apps/:appId/agents/conversations/:conversationId", + async ({ params, request }) => { + await recordRequest("agents.getConversation", request); + const resolved = authorizedConversation( + String(params.appId), + request, + String(params.conversationId), + ); + return resolved.response ?? HttpResponse.json(resolved.stored!.record); + }, + ), + http.post( + "*/api/apps/:appId/agents/conversations", + async ({ params, request }) => { + await recordRequest("agents.createConversation", request); + const appId = String(params.appId); + const owner = identity(appId, request); + if (!owner) + return HttpResponse.json( + { detail: "User must be authenticated to create a conversation" }, + { status: 401 }, + ); + const input = (await request.clone().json()) as Pick< + PlatformConversation, + "agent_name" + >; + const conversation: PlatformConversation = { + id: `conv-${state.nextConversationId++}`, + agent_name: input.agent_name, + messages: [], + }; + state.conversations.set(conversation.id, { + appId, + owner, + record: conversation, + }); + return HttpResponse.json(conversation); + }, + ), http.post( "*/api/apps/:appId/agents/conversations/v2/:conversationId/messages", async ({ params, request }) => { await recordRequest("agents.addMessage", request); const input = (await request.clone().json()) as Record; - const conversation = state.conversations.find( - (item) => item.id === params.conversationId, + const resolved = authorizedConversation( + String(params.appId), + request, + String(params.conversationId), ); - if (!conversation) - return HttpResponse.json({ detail: "Conversation not found", code: "NOT_FOUND" }, { status: 404 }); + if (resolved.response) return resolved.response; const message = { id: `msg-${state.nextMessageId++}`, ...input }; - conversation.messages.push(message); + resolved.stored!.record.messages.push(message); return HttpResponse.json(message); }, ), diff --git a/tests/mocks/platform/auth.ts b/tests/mocks/platform/auth.ts index 493a4385..aac3dfd8 100644 --- a/tests/mocks/platform/auth.ts +++ b/tests/mocks/platform/auth.ts @@ -14,6 +14,7 @@ export interface LoginAccount { interface Principal { appId: string; user: User; + kind: "user" | "service"; } interface ResetToken { email: string; @@ -50,15 +51,19 @@ function resetTokenStore(appId: string) { return appTokens; } -function bearerToken(request: Request) { - const authorization = request.headers.get("authorization"); +function bearerToken(request: Request, headerName = "authorization") { + const authorization = request.headers.get(headerName); return authorization?.startsWith("Bearer ") ? authorization.slice("Bearer ".length) : undefined; } -function principalFor(appId: string, request: Request) { - const token = bearerToken(request); +export function principalFor( + appId: string, + request: Request, + headerName = "authorization", +) { + const token = bearerToken(request, headerName); if (!token) return undefined; const principal = principals.get(token); return principal?.appId === appId ? { token, principal } : undefined; @@ -84,11 +89,19 @@ export function authFixturesFor(appId: string) { account(account: LoginAccount) { const stored = structuredClone(account); accountStore(appId).set(account.email, stored); - principals.set(account.accessToken, { appId, user: stored.user }); + principals.set(account.accessToken, { + appId, + user: stored.user, + kind: "user", + }); }, principal(token: string, user: User) { const stored = structuredClone(user); - principals.set(token, { appId, user: stored }); + principals.set(token, { appId, user: stored, kind: "user" }); + }, + servicePrincipal(token: string, user: User) { + const stored = structuredClone(user); + principals.set(token, { appId, user: stored, kind: "service" }); }, meLatency(token: string, delayMs: number) { meLatencies.set(scoped(appId, token), delayMs); @@ -197,7 +210,11 @@ export const authHandlers = [ { detail: "Invalid credentials" }, { status: 400 }, ); - principals.set(account.accessToken, { appId, user: account.user }); + principals.set(account.accessToken, { + appId, + user: account.user, + kind: "user", + }); return HttpResponse.json({ access_token: account.accessToken, country_code: account.countryCode ?? null, diff --git a/tests/mocks/platform/connectors.ts b/tests/mocks/platform/connectors.ts index bb74cd79..71fb6285 100644 --- a/tests/mocks/platform/connectors.ts +++ b/tests/mocks/platform/connectors.ts @@ -6,6 +6,7 @@ import { type ConnectorToken, type PlatformFault, } from "./state"; +import { principalFor } from "./auth"; function takeFault(predicate: (fault: PlatformFault) => boolean) { const index = state.faults.findIndex(predicate); @@ -14,92 +15,304 @@ function takeFault(predicate: (fault: PlatformFault) => boolean) { return true; } -function token(integrationType: string, accessToken: string, connectionConfig?: Record | null): ConnectorToken { - return { integrationType, accessToken, ...(connectionConfig === undefined ? {} : { connectionConfig }) }; +function token( + integrationType: string, + accessToken: string, + connectionConfig?: Record | null, +): ConnectorToken { + return { + integrationType, + accessToken, + ...(connectionConfig === undefined ? {} : { connectionConfig }), + }; } -export const connectorFixtures = { - connection(integrationType: string, accessToken: string, connectionConfig?: Record | null) { - state.connectorTokens.set(integrationType, token(integrationType, accessToken, connectionConfig)); - }, - workspaceConnection(connectorId: string, integrationType: string, accessToken: string, connectionConfig?: Record | null) { - state.workspaceConnectorTokens.set(connectorId, token(integrationType, accessToken, connectionConfig)); - }, - appUserConnection(connectorId: string, integrationType: string, accessToken: string, connectionConfig?: Record | null) { - state.appUserConnectorTokens.set(connectorId, token(integrationType, accessToken, connectionConfig)); - }, - proxyOutcome(integrationType: string, outcome: ConnectorProxyOutcome) { - state.connectorProxyOutcomes.set(integrationType, structuredClone(outcome)); - }, -}; +function appStore(stores: Map>, appId: string) { + let store = stores.get(appId); + if (!store) { + store = new Map(); + stores.set(appId, store); + } + return store; +} + +function appUserStore(appId: string, userId: string) { + const users = appStore(state.appUserConnectorTokens, appId); + let store = users.get(userId); + if (!store) { + store = new Map(); + users.set(userId, store); + } + return store; +} + +function appUserRedirectStore(appId: string, userId: string) { + const users = appStore(state.appUserConnectorRedirects, appId); + let store = users.get(userId); + if (!store) { + store = new Map(); + users.set(userId, store); + } + return store; +} + +export function connectorFixturesFor(appId: string) { + return { + connection( + integrationType: string, + accessToken: string, + connectionConfig?: Record | null, + ) { + appStore(state.connectorTokens, appId).set( + integrationType, + token(integrationType, accessToken, connectionConfig), + ); + }, + workspaceConnection( + connectorId: string, + integrationType: string, + accessToken: string, + connectionConfig?: Record | null, + ) { + appStore(state.workspaceConnectorTokens, appId).set( + connectorId, + token(integrationType, accessToken, connectionConfig), + ); + }, + appUserConnection( + userId: string, + connectorId: string, + integrationType: string, + accessToken: string, + connectionConfig?: Record | null, + ) { + appUserStore(appId, userId).set( + connectorId, + token(integrationType, accessToken, connectionConfig), + ); + }, + appUserAuthorization( + userId: string, + connectorId: string, + redirectUrl: string, + ) { + appUserRedirectStore(appId, userId).set(connectorId, redirectUrl); + }, + proxyOutcome(integrationType: string, outcome: ConnectorProxyOutcome) { + appStore(state.connectorProxyOutcomes, appId).set( + integrationType, + structuredClone(outcome), + ); + }, + }; +} -export const connectorFaultFixtures = { - creditsExhausted(integrationType: string) { - state.faults.push({ kind: "connector-credits-exhausted", integrationType }); - }, - meteredTokenRequiresProxy(integrationType: string) { - state.faults.push({ kind: "metered-connector-token-refused", integrationType }); - }, -}; +export function connectorFaultFixturesFor(appId: string) { + return { + creditsExhausted(integrationType: string) { + state.faults.push({ + kind: "connector-credits-exhausted", + appId, + integrationType, + }); + }, + meteredTokenRequiresProxy(integrationType: string) { + state.faults.push({ + kind: "metered-connector-token-refused", + appId, + integrationType, + }); + }, + }; +} + +function unauthorized() { + return HttpResponse.json({ detail: "Unauthorized" }, { status: 401 }); +} + +function serviceOnly() { + return HttpResponse.json( + { detail: "This endpoint is only accessible to service tokens" }, + { status: 403 }, + ); +} function tokenResponse(value: ConnectorToken | undefined) { if (!value) - return HttpResponse.json({ detail: "Connector connection not found", code: "NOT_FOUND" }, { status: 404 }); + return HttpResponse.json( + { detail: "Connector connection not found", code: "NOT_FOUND" }, + { status: 404 }, + ); return HttpResponse.json({ access_token: value.accessToken, integration_type: value.integrationType, - ...(value.connectionConfig === undefined ? {} : { connection_config: value.connectionConfig }), + ...(value.connectionConfig === undefined + ? {} + : { connection_config: value.connectionConfig }), }); } export const connectorHandlers = [ - http.get("*/api/apps/:appId/external-auth/tokens/connectors/:connectorId", async ({ params, request }) => { - await recordRequest("connectors.getWorkspaceConnection", request); - return tokenResponse(state.workspaceConnectorTokens.get(String(params.connectorId))); - }), - http.get("*/api/apps/:appId/external-auth/tokens/:integrationType", async ({ params, request }) => { - await recordRequest("connectors.getConnection", request); - const integrationType = String(params.integrationType); - const metered = takeFault( - (item) => item.kind === "metered-connector-token-refused" && item.integrationType === integrationType, - ); - if (metered) - return HttpResponse.json( - { detail: `Connector '${integrationType}' is metered — raw access tokens are not available for it. Call POST /api/apps/${String(params.appId)}/connectors/${integrationType}/call instead.` }, - { status: 403, headers: { "X-Base44-Connector-Error": "metered_connector_requires_proxy" } }, + http.get( + "*/api/apps/:appId/external-auth/tokens/connectors/:connectorId", + async ({ params, request }) => { + await recordRequest("connectors.getWorkspaceConnection", request); + const appId = String(params.appId); + const principal = principalFor(appId, request)?.principal; + if (!principal) return unauthorized(); + if (principal.kind !== "service") return serviceOnly(); + return tokenResponse( + state.workspaceConnectorTokens + .get(appId) + ?.get(String(params.connectorId)), ); - return tokenResponse(state.connectorTokens.get(integrationType)); - }), - http.get("*/api/apps/:appId/app-user-auth/connectors/:connectorId/token", async ({ params, request }) => { - await recordRequest("connectors.getCurrentAppUserConnection", request); - return tokenResponse(state.appUserConnectorTokens.get(String(params.connectorId))); - }), - http.post("*/api/apps/:appId/connectors/:integrationType/call", async ({ params, request }) => { - await recordRequest("connectors.callApi", request); - const integrationType = String(params.integrationType); - const exhausted = takeFault( - (item) => item.kind === "connector-credits-exhausted" && item.integrationType === integrationType, - ); - if (exhausted) - return HttpResponse.json( - { - message: "You have reached the limit of integrations for this month", - extra_data: { reason: "integration_credits_limit_reached" }, - }, - { status: 402 }, + }, + ), + http.get( + "*/api/apps/:appId/external-auth/tokens/:integrationType", + async ({ params, request }) => { + await recordRequest("connectors.getConnection", request); + const appId = String(params.appId); + const principal = principalFor(appId, request)?.principal; + if (!principal) return unauthorized(); + if (principal.kind !== "service") return serviceOnly(); + const integrationType = String(params.integrationType); + const metered = takeFault( + (item) => + item.kind === "metered-connector-token-refused" && + item.appId === appId && + item.integrationType === integrationType, + ); + if (metered) + return HttpResponse.json( + { + detail: `Connector '${integrationType}' is metered — raw access tokens are not available for it. Call POST /api/apps/${String(params.appId)}/connectors/${integrationType}/call instead.`, + }, + { + status: 403, + headers: { + "X-Base44-Connector-Error": "metered_connector_requires_proxy", + }, + }, + ); + return tokenResponse( + state.connectorTokens.get(appId)?.get(integrationType), + ); + }, + ), + http.get( + "*/api/apps/:appId/app-user-auth/connectors/:connectorId/token", + async ({ params, request }) => { + await recordRequest("connectors.getCurrentAppUserConnection", request); + const appId = String(params.appId); + const principal = principalFor(appId, request)?.principal; + if (!principal) return unauthorized(); + if (principal.kind !== "service") return serviceOnly(); + const user = principalFor(appId, request, "on-behalf-of")?.principal.user; + if (!user) return unauthorized(); + return tokenResponse( + state.appUserConnectorTokens + .get(appId) + ?.get(user.id) + ?.get(String(params.connectorId)), + ); + }, + ), + http.post( + "*/api/apps/:appId/connectors/:integrationType/call", + async ({ params, request }) => { + await recordRequest("connectors.callApi", request); + const appId = String(params.appId); + const principal = principalFor(appId, request)?.principal; + if (!principal) return unauthorized(); + if (principal.kind !== "service") return serviceOnly(); + const integrationType = String(params.integrationType); + const exhausted = takeFault( + (item) => + item.kind === "connector-credits-exhausted" && + item.appId === appId && + item.integrationType === integrationType, ); - const outcome = state.connectorProxyOutcomes.get(integrationType); - if (!outcome) - return HttpResponse.json({ detail: "Connector proxy not configured", code: "NOT_FOUND" }, { status: 404 }); - return HttpResponse.json({ - success: outcome.success, - phase: outcome.phase, - status_code: outcome.status, - data: outcome.data, - ...(outcome.dataBase64 === undefined ? {} : { data_base64: outcome.dataBase64 }), - ...(outcome.contentType === undefined ? {} : { content_type: outcome.contentType }), - headers: outcome.headers ?? {}, - credits_charged: outcome.creditsCharged ?? 0, - }); - }), + if (exhausted) + return HttpResponse.json( + { + message: + "You have reached the limit of integrations for this month", + extra_data: { reason: "integration_credits_limit_reached" }, + }, + { status: 402 }, + ); + const outcome = state.connectorProxyOutcomes + .get(appId) + ?.get(integrationType); + if (!outcome) + return HttpResponse.json( + { detail: "Connector proxy not configured", code: "NOT_FOUND" }, + { status: 404 }, + ); + return HttpResponse.json({ + success: outcome.success, + phase: outcome.phase, + status_code: outcome.status, + data: outcome.data, + ...(outcome.dataBase64 === undefined + ? {} + : { data_base64: outcome.dataBase64 }), + ...(outcome.contentType === undefined + ? {} + : { content_type: outcome.contentType }), + headers: outcome.headers ?? {}, + credits_charged: outcome.creditsCharged ?? 0, + }); + }, + ), + http.post( + "*/api/apps/:appId/app-user-auth/connectors/:connectorId/initiate", + async ({ params, request }) => { + await recordRequest("connectors.connectAppUser", request); + const appId = String(params.appId); + const user = principalFor(appId, request)?.principal.user; + if (!user) return unauthorized(); + const redirectUrl = state.appUserConnectorRedirects + .get(appId) + ?.get(user.id) + ?.get(String(params.connectorId)); + return redirectUrl + ? HttpResponse.json({ + redirect_url: redirectUrl, + connection_id: `connection-${String(params.connectorId)}`, + already_authorized: false, + }) + : HttpResponse.json( + { + detail: "Connector authorization not configured", + code: "NOT_FOUND", + }, + { status: 404 }, + ); + }, + ), + http.delete( + "*/api/apps/:appId/app-user-auth/connectors/:connectorId", + async ({ params, request }) => { + await recordRequest("connectors.disconnectAppUser", request); + const appId = String(params.appId); + const user = principalFor(appId, request)?.principal.user; + if (!user) return unauthorized(); + const disconnected = + state.appUserConnectorTokens + .get(appId) + ?.get(user.id) + ?.delete(String(params.connectorId)) ?? false; + return disconnected + ? HttpResponse.json({ + status: "disconnected", + connector_id: String(params.connectorId), + }) + : HttpResponse.json( + { detail: "No active connection found for this connector" }, + { status: 404 }, + ); + }, + ), ]; diff --git a/tests/mocks/platform/entities.ts b/tests/mocks/platform/entities.ts index 062f7338..26b2b222 100644 --- a/tests/mocks/platform/entities.ts +++ b/tests/mocks/platform/entities.ts @@ -33,6 +33,19 @@ function nextId(appId: string) { return String(id); } +function compareValues(left: unknown, right: unknown) { + if (typeof left === "number" && typeof right === "number") + return left - right; + if (left === right) return 0; + if (left == null) return -1; + if (right == null) return 1; + if (typeof left === "boolean" && typeof right === "boolean") + return Number(left) - Number(right); + const leftText = String(left); + const rightText = String(right); + return leftText < rightText ? -1 : leftText > rightText ? 1 : 0; +} + function select(records: PlatformRecord[], request: Request) { const search = new URL(request.url).searchParams; const query = search.get("q"); @@ -44,9 +57,7 @@ function select(records: PlatformRecord[], request: Request) { const descending = sort.startsWith("-"); const field = descending ? sort.slice(1) : sort; selected.sort((left, right) => { - const comparison = String(left[field]).localeCompare( - String(right[field]), - ); + const comparison = compareValues(left[field], right[field]); return descending ? -comparison : comparison; }); } diff --git a/tests/mocks/platform/functions.ts b/tests/mocks/platform/functions.ts index 58b1c538..ca478c01 100644 --- a/tests/mocks/platform/functions.ts +++ b/tests/mocks/platform/functions.ts @@ -60,7 +60,7 @@ export const functionHandlers = [ query: recorded.query, headers: recorded.headers, }); - return HttpResponse.json(result); + return HttpResponse.json(result as any); }, ), // The SDK also exposes this legacy, non-app-scoped alias. It is not present diff --git a/tests/mocks/platform/index.ts b/tests/mocks/platform/index.ts index 9ee548a2..25395e16 100644 --- a/tests/mocks/platform/index.ts +++ b/tests/mocks/platform/index.ts @@ -9,18 +9,18 @@ import { resetAuthState, } from "./auth"; import { - connectorFaultFixtures, - connectorFixtures, + connectorFaultFixturesFor, + connectorFixturesFor, connectorHandlers, } from "./connectors"; import { entityHandlers } from "./entities"; import { functionHandlers } from "./functions"; import { genericFixtures, genericHandlers, resetGenericState } from "./generic"; import { - customIntegrationFaultFixtures, - customIntegrationFixtures, - integrationFaultFixtures, - integrationFixtures, + customIntegrationFaultFixturesFor, + customIntegrationFixturesFor, + integrationFaultFixturesFor, + integrationFixturesFor, integrationHandlers, } from "./integrations"; import { resetSsoState, ssoFixtures, ssoHandlers } from "./sso"; @@ -86,7 +86,35 @@ function multipart(body: unknown) { function forApp(appId: string) { const auth = authFixturesFor(appId); const authFaults = authFaultFixturesFor(appId); + const integrations = integrationFixturesFor(appId); + const customIntegrations = customIntegrationFixturesFor(appId); + const connectors = connectorFixturesFor(appId); return { + workspace(workspaceId: string) { + state.appWorkspaces.set(appId, workspaceId); + }, + agents: { + conversationsForUser( + userId: string, + conversations: PlatformConversation[], + ) { + arrangeConversations( + appId, + { kind: "user", id: userId }, + conversations, + ); + }, + conversationsForVisitor( + visitorId: string, + conversations: PlatformConversation[], + ) { + arrangeConversations( + appId, + { kind: "visitor", id: visitorId }, + conversations, + ); + }, + }, entities: { records(entityName: string, records: PlatformRecord[]) { let appEntities = state.entities.get(appId); @@ -187,6 +215,9 @@ function forApp(appId: string) { })); }, }, + integrations, + customIntegrations, + connectors, faults: { auth: { ...authFaults, @@ -221,44 +252,48 @@ function forApp(appId: string) { }); }, }, + integrations: integrationFaultFixturesFor(appId), + customIntegrations: customIntegrationFaultFixturesFor(appId), + connectors: connectorFaultFixturesFor(appId), }, }; } +function arrangeConversations( + appId: string, + owner: { kind: "user" | "visitor"; id: string }, + conversations: PlatformConversation[], +) { + for (const conversation of conversations) + state.conversations.set(conversation.id, { + appId, + owner: clone(owner), + record: clone(conversation), + }); + const numericIds = conversations + .map((conversation) => Number(conversation.id.match(/\d+$/)?.[0])) + .filter(Number.isFinite); + state.nextConversationId = Math.max( + state.nextConversationId, + ...numericIds.map((id) => id + 1), + ); +} + const appGiven = Object.assign(forApp, appFixtures); export const platform = { reset, given: { app: appGiven, - agents: { - conversations(conversations: PlatformConversation[]) { - state.conversations = clone(conversations); - const numericIds = conversations - .map((conversation) => Number(conversation.id.match(/\d+$/)?.[0])) - .filter(Number.isFinite); - state.nextConversationId = Math.max( - state.nextConversationId, - ...numericIds.map((id) => id + 1), - ); - }, - }, functions: { legacyEndpoint(functionPath: string) { state.legacyFunctions.add(functionPath.replace(/^\//, "")); }, }, - integrations: integrationFixtures, - customIntegrations: customIntegrationFixtures, - connectors: connectorFixtures, actors: actorFixtures, sso: ssoFixtures, generic: genericFixtures, - faults: { - integrations: integrationFaultFixtures, - customIntegrations: customIntegrationFaultFixtures, - connectors: connectorFaultFixtures, - }, + faults: {}, }, requests: { all(route?: string): RecordedRequest[] { diff --git a/tests/mocks/platform/integrations.ts b/tests/mocks/platform/integrations.ts index 4f2a146f..4a642ae0 100644 --- a/tests/mocks/platform/integrations.ts +++ b/tests/mocks/platform/integrations.ts @@ -11,33 +11,62 @@ function takeFault(predicate: (fault: PlatformFault) => boolean) { return true; } -export const integrationFixtures = { - packageSucceeds(packageName: string, endpointName: string, result: Record = {}) { - state.integrationEndpoints.set(endpointKey(packageName, endpointName), { - response: { success: true, ...result }, - }); - }, - emailDelivered(messageId = "123456") { - this.packageSucceeds("Core", "SendEmail", { messageId }); - }, - fileUploaded(fileId = "file123") { - this.packageSucceeds("Core", "UploadFile", { fileId }); - }, - llmResponds(response: unknown) { - state.integrationEndpoints.set(endpointKey("Core", "InvokeLLM"), { response }); - }, -}; +function integrationStore(appId: string) { + let endpoints = state.integrationEndpoints.get(appId); + if (!endpoints) { + endpoints = new Map(); + state.integrationEndpoints.set(appId, endpoints); + } + return endpoints; +} + +export function integrationFixturesFor(appId: string) { + return { + packageSucceeds( + packageName: string, + endpointName: string, + result: Record = {}, + ) { + integrationStore(appId).set(endpointKey(packageName, endpointName), { + response: { success: true, ...result }, + }); + }, + emailDelivered(messageId = "123456") { + this.packageSucceeds("Core", "SendEmail", { messageId }); + }, + fileUploaded(fileId = "file123") { + this.packageSucceeds("Core", "UploadFile", { fileId }); + }, + llmResponds(response: unknown) { + integrationStore(appId).set(endpointKey("Core", "InvokeLLM"), { + response, + }); + }, + }; +} -export const integrationFaultFixtures = { - invalidParameters(packageName: string, endpointName: string) { - state.faults.push({ kind: "integration-invalid-parameters", packageName, endpointName }); - }, -}; +export function integrationFaultFixturesFor(appId: string) { + return { + invalidParameters(packageName: string, endpointName: string) { + state.faults.push({ + kind: "integration-invalid-parameters", + appId, + packageName, + endpointName, + }); + }, + }; +} -function invokeIntegration(packageName: string, endpointName: string) { +function invokeIntegration( + appId: string, + packageName: string, + endpointName: string, +) { const fault = takeFault( (item) => item.kind === "integration-invalid-parameters" && + item.appId === appId && item.packageName === packageName && item.endpointName === endpointName, ); @@ -46,46 +75,92 @@ function invokeIntegration(packageName: string, endpointName: string) { { detail: "Invalid parameters", code: "INVALID_PARAMS" }, { status: 400 }, ); - const endpoint = state.integrationEndpoints.get(endpointKey(packageName, endpointName)); + const endpoint = integrationStore(appId).get( + endpointKey(packageName, endpointName), + ); if (!endpoint) return HttpResponse.json( - { detail: `Integration endpoint '${packageName}.${endpointName}' not found`, code: "NOT_FOUND" }, + { + detail: `Integration endpoint '${packageName}.${endpointName}' not found`, + code: "NOT_FOUND", + }, { status: 404 }, ); - return HttpResponse.json(endpoint.response); + return HttpResponse.json(endpoint.response as any); } function parseCustomRoute(request: Request) { const match = new URL(request.url).pathname.match( - /^\/api\/apps\/[^/]+\/integrations\/custom\/([^/]+)\/(.+)$/, + /^\/api\/apps\/([^/]+)\/integrations\/custom\/([^/]+)\/(.+)$/, ); if (!match) return undefined; - return { slug: decodeURIComponent(match[1]), operationId: decodeURIComponent(match[2]) }; + return { + appId: decodeURIComponent(match[1]), + slug: decodeURIComponent(match[2]), + operationId: decodeURIComponent(match[3]), + }; +} + +function workspaceOperations(workspaceId: string, slug: string) { + let integrations = state.customIntegrations.get(workspaceId); + if (!integrations) { + integrations = new Map(); + state.customIntegrations.set(workspaceId, integrations); + } + let operations = integrations.get(slug); + if (!operations) { + operations = new Map(); + integrations.set(slug, operations); + } + return operations; +} + +function workspaceFor(appId: string) { + const workspaceId = state.appWorkspaces.get(appId); + if (!workspaceId) + throw new Error(`Arrange a workspace for app '${appId}' first`); + return workspaceId; } -export const customIntegrationFixtures = { - operation(slug: string, operationId: string, data: unknown, statusCode = 200) { - let operations = state.customIntegrations.get(slug); - if (!operations) { - operations = new Map(); - state.customIntegrations.set(slug, operations); - } - operations.set(operationId, { data, statusCode }); - }, -}; +export function customIntegrationFixturesFor(appId: string) { + return { + operation( + slug: string, + operationId: string, + data: unknown, + statusCode = 200, + ) { + workspaceOperations(workspaceFor(appId), slug).set(operationId, { + data, + statusCode, + }); + }, + }; +} -export const customIntegrationFaultFixtures = { - upstreamUnavailable(slug: string, operationId: string) { - state.faults.push({ kind: "custom-upstream-unavailable", slug, operationId }); - }, -}; +export function customIntegrationFaultFixturesFor(appId: string) { + return { + upstreamUnavailable(slug: string, operationId: string) { + state.faults.push({ + kind: "custom-upstream-unavailable", + workspaceId: workspaceFor(appId), + slug, + operationId, + }); + }, + }; +} export const integrationHandlers = [ http.post( "*/api/apps/:appId/integration-endpoints/Core/:endpointName", async ({ params, request }) => { await recordRequest("integrations.invoke", request); - return invokeIntegration("Core", String(params.endpointName)); + return invokeIntegration( + String(params.appId), + "Core", + String(params.endpointName), + ); }, ), http.post( @@ -94,43 +169,61 @@ export const integrationHandlers = [ "*/api/apps/:appId/integration-endpoints/installable/:packageName/integration-endpoints/:endpointName", async ({ params, request }) => { await recordRequest("integrations.invoke", request); - return invokeIntegration(String(params.packageName), String(params.endpointName)); + return invokeIntegration( + String(params.appId), + String(params.packageName), + String(params.endpointName), + ); }, ), - http.post(/^https?:\/\/[^/]+\/api\/apps\/[^/]+\/integrations\/custom\/.+$/, async ({ request }) => { - await recordRequest("customIntegrations.call", request); - const route = parseCustomRoute(request); - if (!route) - return HttpResponse.json({ detail: "Custom integration route not found" }, { status: 404 }); - const { slug, operationId } = route; - const upstreamFault = takeFault( - (item) => - item.kind === "custom-upstream-unavailable" && - item.slug === slug && - item.operationId === operationId, - ); - if (upstreamFault) + http.post( + /^https?:\/\/[^/]+\/api\/apps\/[^/]+\/integrations\/custom\/.+$/, + async ({ request }) => { + await recordRequest("customIntegrations.call", request); + const route = parseCustomRoute(request); + if (!route) + return HttpResponse.json( + { detail: "Custom integration route not found" }, + { status: 404 }, + ); + const { appId, slug, operationId } = route; + const workspaceId = state.appWorkspaces.get(appId); + if (!workspaceId) + return HttpResponse.json( + { detail: `Custom integration '${slug}' not found in workspace` }, + { status: 404 }, + ); + const upstreamFault = takeFault( + (item) => + item.kind === "custom-upstream-unavailable" && + item.workspaceId === workspaceId && + item.slug === slug && + item.operationId === operationId, + ); + if (upstreamFault) + return HttpResponse.json( + { detail: "Failed to connect to external API: Connection refused" }, + { status: 502 }, + ); + const operations = state.customIntegrations.get(workspaceId)?.get(slug); + if (!operations) + return HttpResponse.json( + { detail: `Custom integration '${slug}' not found in workspace` }, + { status: 404 }, + ); + const operation = operations.get(operationId); + if (!operation) + return HttpResponse.json( + { + detail: `Operation '${operationId}' not found in integration '${slug}'`, + }, + { status: 404 }, + ); return HttpResponse.json({ - success: false, - status_code: 502, - data: { detail: "Failed to connect to external API: Connection refused" }, + success: true, + status_code: operation.statusCode, + data: operation.data, }); - const operations = state.customIntegrations.get(slug); - if (!operations) - return HttpResponse.json( - { detail: `Custom integration '${slug}' not found in workspace` }, - { status: 404 }, - ); - const operation = operations.get(operationId); - if (!operation) - return HttpResponse.json( - { detail: `Operation '${operationId}' not found in integration '${slug}'` }, - { status: 404 }, - ); - return HttpResponse.json({ - success: true, - status_code: operation.statusCode, - data: operation.data, - }); - }), + }, + ), ]; diff --git a/tests/mocks/platform/state.ts b/tests/mocks/platform/state.ts index 7cfd6798..32b80a1d 100644 --- a/tests/mocks/platform/state.ts +++ b/tests/mocks/platform/state.ts @@ -26,6 +26,12 @@ export interface PlatformConversation extends PlatformRecord { messages: PlatformRecord[]; } +export interface StoredConversation { + appId: string; + owner: { kind: "user" | "visitor"; id: string }; + record: PlatformConversation; +} + export interface PlatformRegistration { id: string; message: string; @@ -74,12 +80,26 @@ export type FunctionBehavior = ( export type PlatformFault = | { kind: "integration-invalid-parameters"; + appId: string; packageName: string; endpointName: string; } - | { kind: "custom-upstream-unavailable"; slug: string; operationId: string } - | { kind: "connector-credits-exhausted"; integrationType: string } - | { kind: "metered-connector-token-refused"; integrationType: string } + | { + kind: "custom-upstream-unavailable"; + workspaceId: string; + slug: string; + operationId: string; + } + | { + kind: "connector-credits-exhausted"; + appId: string; + integrationType: string; + } + | { + kind: "metered-connector-token-refused"; + appId: string; + integrationType: string; + } | { kind: "auth-registration-rejected"; appId: string; email: string } | { kind: "function-internal-error"; appId: string; functionName: string } | { kind: "function-not-found"; appId: string; functionName: string } @@ -91,13 +111,18 @@ export type PlatformFault = interface PlatformState { entities: Map>; - conversations: PlatformConversation[]; - integrationEndpoints: Map; - customIntegrations: Map>; - connectorTokens: Map; - workspaceConnectorTokens: Map; - appUserConnectorTokens: Map; - connectorProxyOutcomes: Map; + conversations: Map; + integrationEndpoints: Map>; + appWorkspaces: Map; + customIntegrations: Map< + string, + Map> + >; + connectorTokens: Map>; + workspaceConnectorTokens: Map>; + appUserConnectorTokens: Map>>; + appUserConnectorRedirects: Map>>; + connectorProxyOutcomes: Map>; registrations: Map; passwordResetRequestMessages: Map; functionBehaviors: Map>; @@ -111,12 +136,14 @@ interface PlatformState { export const state: PlatformState = { entities: new Map(), - conversations: [], + conversations: new Map(), integrationEndpoints: new Map(), + appWorkspaces: new Map(), customIntegrations: new Map(), connectorTokens: new Map(), workspaceConnectorTokens: new Map(), appUserConnectorTokens: new Map(), + appUserConnectorRedirects: new Map(), connectorProxyOutcomes: new Map(), registrations: new Map(), passwordResetRequestMessages: new Map(), @@ -131,12 +158,14 @@ export const state: PlatformState = { export function resetPlatformState() { state.entities.clear(); - state.conversations = []; + state.conversations.clear(); state.integrationEndpoints.clear(); + state.appWorkspaces.clear(); state.customIntegrations.clear(); state.connectorTokens.clear(); state.workspaceConnectorTokens.clear(); state.appUserConnectorTokens.clear(); + state.appUserConnectorRedirects.clear(); state.connectorProxyOutcomes.clear(); state.registrations.clear(); state.passwordResetRequestMessages.clear(); diff --git a/tests/unit/agents.test.ts b/tests/unit/agents.test.ts index c61042d6..54527b84 100644 --- a/tests/unit/agents.test.ts +++ b/tests/unit/agents.test.ts @@ -6,10 +6,13 @@ describe("Agents Module", () => { let base44: ReturnType; const serverUrl = "https://api.base44.com"; const appId = "test-app-id"; + const userToken = "agent-user-token"; + const userId = "agent-user"; beforeEach(() => { platform.reset(); - base44 = createClient({ serverUrl, appId }); + platform.given.app(appId).auth.principal(userToken, { id: userId }); + base44 = createClient({ serverUrl, appId, token: userToken }); }); afterEach(() => base44.cleanup()); @@ -19,27 +22,43 @@ describe("Agents Module", () => { { id: "conv-1", agent_name: "support", messages: [] }, { id: "conv-2", agent_name: "sales", messages: [] }, ]; - platform.given.agents.conversations(conversations); + platform.given + .app(appId) + .agents.conversationsForUser(userId, conversations); - await expect(base44.agents.getConversations()).resolves.toEqual(conversations); + await expect(base44.agents.getConversations()).resolves.toEqual( + conversations, + ); expect(platform.requests.count("agents.listConversations")).toBe(1); }); test("getConversation() returns one arranged conversation", async () => { const conversation = { id: "conv-1", agent_name: "support", messages: [] }; - platform.given.agents.conversations([conversation]); + platform.given + .app(appId) + .agents.conversationsForUser(userId, [conversation]); - await expect(base44.agents.getConversation("conv-1")).resolves.toEqual(conversation); + await expect(base44.agents.getConversation("conv-1")).resolves.toEqual( + conversation, + ); }); test("createConversation() persists so list and get observe it", async () => { - platform.given.agents.conversations([]); - - const created = await base44.agents.createConversation({ agent_name: "support" }); + const created = await base44.agents.createConversation({ + agent_name: "support", + }); - expect(created).toEqual({ id: "conv-1", agent_name: "support", messages: [] }); - await expect(base44.agents.getConversation(created.id)).resolves.toEqual(created); - await expect(base44.agents.getConversations()).resolves.toContainEqual(created); + expect(created).toEqual({ + id: "conv-1", + agent_name: "support", + messages: [], + }); + await expect(base44.agents.getConversation(created.id)).resolves.toEqual( + created, + ); + await expect(base44.agents.getConversations()).resolves.toContainEqual( + created, + ); expect(platform.requests.last("agents.createConversation").body).toEqual({ agent_name: "support", }); @@ -47,7 +66,9 @@ describe("Agents Module", () => { test("addMessage() posts to v2 and updates conversation state", async () => { const conversation = { id: "conv-1", agent_name: "support", messages: [] }; - platform.given.agents.conversations([conversation]); + platform.given + .app(appId) + .agents.conversationsForUser(userId, [conversation]); const message = await base44.agents.addMessage(conversation, { role: "user", @@ -55,7 +76,9 @@ describe("Agents Module", () => { }); expect(message).toEqual({ id: "msg-1", role: "user", content: "Hi" }); - await expect(base44.agents.getConversation("conv-1")).resolves.toMatchObject({ + await expect( + base44.agents.getConversation("conv-1"), + ).resolves.toMatchObject({ messages: [message], }); expect(platform.requests.last("agents.addMessage").body).toEqual({ @@ -65,9 +88,11 @@ describe("Agents Module", () => { }); test("getWhatsAppConnectURL omits the token when unauthenticated", () => { - expect(base44.agents.getWhatsAppConnectURL("support")).toBe( + const anonymous = createClient({ serverUrl, appId }); + expect(anonymous.agents.getWhatsAppConnectURL("support")).toBe( `${serverUrl}/api/apps/${appId}/agents/support/whatsapp`, ); + anonymous.cleanup(); }); test("getWhatsAppConnectURL includes the token when authenticated", () => { @@ -80,14 +105,16 @@ describe("Agents Module", () => { test("getWhatsAppConnectURL encodes the agent name", () => { expect(base44.agents.getWhatsAppConnectURL("my agent")).toBe( - `${serverUrl}/api/apps/${appId}/agents/my%20agent/whatsapp`, + `${serverUrl}/api/apps/${appId}/agents/my%20agent/whatsapp?token=${userToken}`, ); }); test("getTelegramConnectURL omits the token when unauthenticated", () => { - expect(base44.agents.getTelegramConnectURL("support")).toBe( + const anonymous = createClient({ serverUrl, appId }); + expect(anonymous.agents.getTelegramConnectURL("support")).toBe( `${serverUrl}/api/apps/${appId}/agents/support/telegram`, ); + anonymous.cleanup(); }); test("getTelegramConnectURL includes the token when authenticated", () => { @@ -100,14 +127,108 @@ describe("Agents Module", () => { test("getTelegramConnectURL encodes the agent name", () => { expect(base44.agents.getTelegramConnectURL("my agent")).toBe( - `${serverUrl}/api/apps/${appId}/agents/my%20agent/telegram`, + `${serverUrl}/api/apps/${appId}/agents/my%20agent/telegram?token=${userToken}`, ); }); - test("reset() isolates conversations and request history", async () => { - platform.given.agents.conversations([ - { id: "conv-1", agent_name: "support", messages: [] }, + test("isolates conversations by application and user principal", async () => { + const otherAppId = "other-agent-app"; + const otherToken = "other-agent-token"; + const otherClient = createClient({ + serverUrl, + appId: otherAppId, + token: otherToken, + }); + const sameAppOtherToken = "same-app-other-token"; + const sameAppOther = createClient({ + serverUrl, + appId, + token: sameAppOtherToken, + }); + platform.given + .app(otherAppId) + .auth.principal(otherToken, { id: "other-user" }); + platform.given + .app(appId) + .auth.principal(sameAppOtherToken, { id: "same-app-other" }); + const conversation = { id: "conv-1", agent_name: "support", messages: [] }; + platform.given + .app(appId) + .agents.conversationsForUser(userId, [conversation]); + + await expect(base44.agents.getConversations()).resolves.toEqual([ + conversation, ]); + await expect(otherClient.agents.getConversations()).resolves.toEqual([]); + await expect(sameAppOther.agents.getConversations()).resolves.toEqual([]); + await expect( + otherClient.agents.getConversation("conv-1"), + ).rejects.toMatchObject({ + status: 403, + message: "Access denied: conversation belongs to another app", + }); + await expect( + sameAppOther.agents.addMessage(conversation, { + role: "user", + content: "leak", + }), + ).rejects.toMatchObject({ + status: 403, + message: "Access denied: conversation belongs to another user", + }); + await expect(base44.agents.getConversation("conv-1")).resolves.toEqual( + conversation, + ); + otherClient.cleanup(); + sameAppOther.cleanup(); + }); + + test("requires a user or visitor identity to create conversations", async () => { + const anonymous = createClient({ serverUrl, appId }); + await expect(anonymous.agents.getConversations()).resolves.toEqual([]); + await expect( + anonymous.agents.createConversation({ agent_name: "support" }), + ).rejects.toMatchObject({ + status: 401, + message: "User must be authenticated to create a conversation", + }); + anonymous.cleanup(); + }); + + test("isolates anonymous visitor conversations", async () => { + const visitorA = createClient({ + serverUrl, + appId, + headers: { "X-Base44-Anonymous-Id": "visitor-a" }, + }); + const visitorB = createClient({ + serverUrl, + appId, + headers: { "X-Base44-Anonymous-Id": "visitor-b" }, + }); + platform.given + .app(appId) + .agents.conversationsForVisitor("visitor-a", [ + { id: "conv-visitor", agent_name: "guide", messages: [] }, + ]); + await expect(visitorA.agents.getConversations()).resolves.toHaveLength(1); + await expect(visitorB.agents.getConversations()).resolves.toEqual([]); + await expect( + visitorB.agents.getConversation("conv-visitor"), + ).rejects.toMatchObject({ + status: 403, + message: "Access denied: conversation belongs to another visitor", + }); + visitorA.cleanup(); + visitorB.cleanup(); + }); + + test("reset() isolates conversations and request history", async () => { + platform.given + .app(appId) + .agents.conversationsForUser(userId, [ + { id: "conv-1", agent_name: "support", messages: [] }, + ]); await base44.agents.getConversations(); platform.reset(); diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index efc1e926..56898688 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -400,7 +400,7 @@ describe("Service Role Authorization Headers", () => { serviceToken: serviceToken, }); - platform.given.integrations.emailDelivered("123"); + platform.given.app(appId).integrations.emailDelivered("123"); // Make request const result = await client.asServiceRole.integrations.Core.SendEmail({ @@ -455,7 +455,7 @@ describe("Service Role Authorization Headers", () => { platform.given .app(appId) .entities.records("Task", [{ id: "task1", title: "User Task" }]); - platform.given.integrations.emailDelivered("email123"); + platform.given.app(appId).integrations.emailDelivered("email123"); // Make requests using regular client (not service role) const taskResult = await client.entities.Task.list(); diff --git a/tests/unit/connectors-proxy.test.ts b/tests/unit/connectors-proxy.test.ts index 8446ed26..0d5c34a4 100644 --- a/tests/unit/connectors-proxy.test.ts +++ b/tests/unit/connectors-proxy.test.ts @@ -10,127 +10,271 @@ const responded = { headers: { "x-rate-limit-remaining": "42" }, creditsCharged: 3, }; +const appId = "test-app-id"; +const serviceToken = "service-token-123"; describe("Connectors module – metered connector proxy", () => { let base44: ReturnType; beforeEach(() => { - base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id", serviceToken: "service-token-123" }); - platform.given.connectors.proxyOutcome("x", responded); + platform.given + .app(appId) + .auth.servicePrincipal(serviceToken, { id: "service-principal" }); + base44 = createClient({ + serverUrl: "https://base44.app", + appId, + serviceToken, + }); + platform.given.app(appId).connectors.proxyOutcome("x", responded); }); afterEach(() => base44.cleanup()); test("posts the normalized request to the shared-connector proxy route", async () => { - await base44.asServiceRole.connectors.callApi("x", { method: "POST", path: "/2/tweets", body: { text: "hi" } }); + await base44.asServiceRole.connectors.callApi("x", { + method: "POST", + path: "/2/tweets", + body: { text: "hi" }, + }); expect(platform.requests.last("connectors.callApi")).toMatchObject({ method: "POST", headers: { authorization: "Bearer service-token-123" }, - body: { method: "POST", path: "/2/tweets", body: { text: "hi" }, query: {}, headers: {} }, + body: { + method: "POST", + path: "/2/tweets", + body: { text: "hi" }, + query: {}, + headers: {}, + }, + }); + }); + + test("rejects an app-user bearer on the service-only proxy route", async () => { + const userToken = "ordinary-user-token"; + platform.given + .app(appId) + .auth.principal(userToken, { id: "ordinary-user" }); + const userAsService = createClient({ + serverUrl: "https://base44.app", + appId, + serviceToken: userToken, }); + + await expect( + userAsService.asServiceRole.connectors.callApi("x", { path: "/scope" }), + ).rejects.toMatchObject({ + status: 403, + message: "This endpoint is only accessible to service tokens", + }); + userAsService.cleanup(); }); test("percent-encodes the integration type so it stays on the connectors route", async () => { - platform.given.connectors.proxyOutcome("../evil/route", responded); - const result = await base44.asServiceRole.connectors.callApi("../evil/route" as any, { path: "/x" }); + platform.given + .app(appId) + .connectors.proxyOutcome("../evil/route", responded); + const result = await base44.asServiceRole.connectors.callApi( + "../evil/route" as any, + { path: "/x" }, + ); expect(result.success).toBe(true); - expect(platform.requests.last("connectors.callApi").url).toContain("connectors/..%2Fevil%2Froute/call"); + expect(platform.requests.last("connectors.callApi").url).toContain( + "connectors/..%2Fevil%2Froute/call", + ); }); test("forwards a named host, and omits it entirely when unset", async () => { - platform.given.connectors.proxyOutcome("googlemaps", responded); - await base44.asServiceRole.connectors.callApi("googlemaps", { host: "places", path: "/v1/places:searchText" }); - await base44.asServiceRole.connectors.callApi("googlemaps", { path: "/maps/api/geocode/json" }); - await base44.asServiceRole.connectors.callApi("googlemaps", { host: null as any, path: "/maps/api/geocode/json" }); - const bodies = platform.requests.all("connectors.callApi").map((request) => request.body as Record); + platform.given.app(appId).connectors.proxyOutcome("googlemaps", responded); + await base44.asServiceRole.connectors.callApi("googlemaps", { + host: "places", + path: "/v1/places:searchText", + }); + await base44.asServiceRole.connectors.callApi("googlemaps", { + path: "/maps/api/geocode/json", + }); + await base44.asServiceRole.connectors.callApi("googlemaps", { + host: null as any, + path: "/maps/api/geocode/json", + }); + const bodies = platform.requests + .all("connectors.callApi") + .map((request) => request.body as Record); expect(bodies[0].host).toBe("places"); expect("host" in bodies[1]).toBe(false); expect("host" in bodies[2]).toBe(false); }); test("maps a binary response to dataBase64 + contentType", async () => { - platform.given.connectors.proxyOutcome("googlemaps", { - success: true, phase: "responded", status: 200, data: null, - dataBase64: "iVBORw0KGgo=", contentType: "image/png", creditsCharged: 1, + platform.given.app(appId).connectors.proxyOutcome("googlemaps", { + success: true, + phase: "responded", + status: 200, + data: null, + dataBase64: "iVBORw0KGgo=", + contentType: "image/png", + creditsCharged: 1, + }); + const result = await base44.asServiceRole.connectors.callApi("googlemaps", { + path: "/maps/api/staticmap", + }); + expect(result).toMatchObject({ + dataBase64: "iVBORw0KGgo=", + contentType: "image/png", + data: null, }); - const result = await base44.asServiceRole.connectors.callApi("googlemaps", { path: "/maps/api/staticmap" }); - expect(result).toMatchObject({ dataBase64: "iVBORw0KGgo=", contentType: "image/png", data: null }); }); test("leaves dataBase64 and contentType null for a JSON response", async () => { - const result = await base44.asServiceRole.connectors.callApi("x", { path: "/2/users/me" }); + const result = await base44.asServiceRole.connectors.callApi("x", { + path: "/2/users/me", + }); expect(result.dataBase64).toBeNull(); expect(result.contentType).toBeNull(); }); test("defaults the method to GET", async () => { await base44.asServiceRole.connectors.callApi("x", { path: "/2/users/me" }); - expect(platform.requests.last("connectors.callApi").body).toMatchObject({ method: "GET" }); + expect(platform.requests.last("connectors.callApi").body).toMatchObject({ + method: "GET", + }); }); test("forwards query parameters so the priced call matches the sent call", async () => { const query = { query: "base44", max_results: 10 }; - await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets/search/recent", query }); - expect(platform.requests.last("connectors.callApi").body).toMatchObject({ query }); + await base44.asServiceRole.connectors.callApi("x", { + path: "/2/tweets/search/recent", + query, + }); + expect(platform.requests.last("connectors.callApi").body).toMatchObject({ + query, + }); }); test("maps the proxy envelope to camelCase", async () => { - const result = await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }); + const result = await base44.asServiceRole.connectors.callApi("x", { + path: "/2/tweets", + }); expect(result).toEqual({ - success: true, phase: "responded", status: 201, data: { data: { id: "1" } }, - dataBase64: null, contentType: null, headers: { "x-rate-limit-remaining": "42" }, creditsCharged: 3, + success: true, + phase: "responded", + status: 201, + data: { data: { id: "1" } }, + dataBase64: null, + contentType: null, + headers: { "x-rate-limit-remaining": "42" }, + creditsCharged: 3, }); }); + test("isolates proxy outcomes by application", async () => { + const otherAppId = "other-proxy-app"; + const otherServiceToken = "other-proxy-service"; + platform.given.app(otherAppId).auth.servicePrincipal(otherServiceToken, { + id: "other-service-principal", + }); + platform.given.app(otherAppId).connectors.proxyOutcome("x", { + ...responded, + status: 202, + data: { app: "other" }, + }); + const otherClient = createClient({ + serverUrl: "https://base44.app", + appId: otherAppId, + serviceToken: otherServiceToken, + }); + + await expect( + base44.asServiceRole.connectors.callApi("x", { path: "/scope" }), + ).resolves.toMatchObject({ status: 201, data: { data: { id: "1" } } }); + await expect( + otherClient.asServiceRole.connectors.callApi("x", { path: "/scope" }), + ).resolves.toMatchObject({ status: 202, data: { app: "other" } }); + otherClient.cleanup(); + }); + test("returns an upstream error instead of throwing", async () => { - platform.given.connectors.proxyOutcome("x", { - success: false, phase: "responded", status: 400, - data: { title: "Invalid Request" }, creditsCharged: 3, + platform.given.app(appId).connectors.proxyOutcome("x", { + success: false, + phase: "responded", + status: 400, + data: { title: "Invalid Request" }, + creditsCharged: 3, + }); + const result = await base44.asServiceRole.connectors.callApi("x", { + method: "POST", + path: "/2/tweets", + body: {}, }); - const result = await base44.asServiceRole.connectors.callApi("x", { method: "POST", path: "/2/tweets", body: {} }); expect(result).toMatchObject({ - success: false, phase: "responded", status: 400, - data: { title: "Invalid Request" }, creditsCharged: 3, + success: false, + phase: "responded", + status: 400, + data: { title: "Invalid Request" }, + creditsCharged: 3, }); }); test("rejects when Base44 itself refuses the call", async () => { - platform.given.faults.connectors.creditsExhausted("x"); - await expect(base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" })).rejects.toMatchObject({ status: 402 }); - await expect(base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" })).resolves.toMatchObject({ + platform.given.app(appId).faults.connectors.creditsExhausted("x"); + await expect( + base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }), + ).rejects.toMatchObject({ status: 402 }); + await expect( + base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }), + ).resolves.toMatchObject({ success: true, status: 201, }); }); test("a metered connector's token request surfaces the actionable refusal", async () => { - platform.given.faults.connectors.meteredTokenRequiresProxy("x"); - await expect(base44.asServiceRole.connectors.getConnection("x")).rejects.toMatchObject({ + platform.given.app(appId).faults.connectors.meteredTokenRequiresProxy("x"); + await expect( + base44.asServiceRole.connectors.getConnection("x"), + ).rejects.toMatchObject({ status: 403, code: "metered_connector_requires_proxy", message: expect.stringContaining("/connectors/x/call"), }); }); - test.each(["post", "TRACE"])("rejects unsupported request method %s before sending", async (method) => { - await expect(base44.asServiceRole.connectors.callApi("x", { method: method as any, path: "/2/tweets" })).rejects.toThrow( - "Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD", - ); - expect(platform.requests.count("connectors.callApi")).toBe(0); - }); + test.each(["post", "TRACE"])( + "rejects unsupported request method %s before sending", + async (method) => { + await expect( + base44.asServiceRole.connectors.callApi("x", { + method: method as any, + path: "/2/tweets", + }), + ).rejects.toThrow( + "Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD", + ); + expect(platform.requests.count("connectors.callApi")).toBe(0); + }, + ); test.each(["not_sent", "timed_out", "sent_unconfirmed"] as const)( "maps proxy phase %s when no upstream response is available", async (phase) => { - platform.given.connectors.proxyOutcome("x", { - success: false, phase, status: null, data: { error: "request outcome unknown" }, + platform.given.app(appId).connectors.proxyOutcome("x", { + success: false, + phase, + status: null, + data: { error: "request outcome unknown" }, creditsCharged: phase === "not_sent" ? 0 : 3, }); - const result = await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }); + const result = await base44.asServiceRole.connectors.callApi("x", { + path: "/2/tweets", + }); expect(result).toMatchObject({ phase, status: null, success: false }); }, ); - test.each([["", "/2/tweets"], ["x", ""]])("rejects a missing identifier or path (%s, %s)", async (type, path) => { - await expect(base44.asServiceRole.connectors.callApi(type, { path })).rejects.toThrow(/required and must be a string/); + test.each([ + ["", "/2/tweets"], + ["x", ""], + ])("rejects a missing identifier or path (%s, %s)", async (type, path) => { + await expect( + base44.asServiceRole.connectors.callApi(type, { path }), + ).rejects.toThrow(/required and must be a string/); }); }); diff --git a/tests/unit/connectors.test.ts b/tests/unit/connectors.test.ts index 6f923a82..92b47aeb 100644 --- a/tests/unit/connectors.test.ts +++ b/tests/unit/connectors.test.ts @@ -2,17 +2,41 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { createClient } from "../../src/index.ts"; import { platform } from "../mocks/platform/index.ts"; +const appId = "test-app-id"; +const serviceToken = "service-token-123"; +const userToken = "user-token-123"; +const userId = "user-123"; + +function arrangeServicePrincipal() { + platform.given + .app(appId) + .auth.servicePrincipal(serviceToken, { id: "service-principal" }); +} + describe("Connectors module – getConnection", () => { let base44: ReturnType; beforeEach(() => { - base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id", serviceToken: "service-token-123" }); + arrangeServicePrincipal(); + base44 = createClient({ + serverUrl: "https://base44.app", + appId, + serviceToken, + }); }); afterEach(() => base44.cleanup()); test("extracts accessToken and connectionConfig", async () => { - platform.given.connectors.connection("jira", "oauth-token-abc123", { subdomain: "my-company" }); - const connection = await base44.asServiceRole.connectors.getConnection("jira"); - expect(connection).toEqual({ accessToken: "oauth-token-abc123", connectionConfig: { subdomain: "my-company" } }); + platform.given + .app(appId) + .connectors.connection("jira", "oauth-token-abc123", { + subdomain: "my-company", + }); + const connection = + await base44.asServiceRole.connectors.getConnection("jira"); + expect(connection).toEqual({ + accessToken: "oauth-token-abc123", + connectionConfig: { subdomain: "my-company" }, + }); expect(platform.requests.last("connectors.getConnection")).toMatchObject({ method: "GET", headers: { authorization: "Bearer service-token-123" }, @@ -22,76 +46,335 @@ describe("Connectors module – getConnection", () => { test.each([ ["slack", undefined], ["github", null], - ])("returns null config when backend config for %s is %s", async (type, config) => { - platform.given.connectors.connection(type, "token-only", config); - await expect(base44.asServiceRole.connectors.getConnection(type)).resolves.toEqual({ - accessToken: "token-only", connectionConfig: null, - }); - }); + ])( + "returns null config when backend config for %s is %s", + async (type, config) => { + platform.given + .app(appId) + .connectors.connection(type, "token-only", config); + await expect( + base44.asServiceRole.connectors.getConnection(type), + ).resolves.toEqual({ + accessToken: "token-only", + connectionConfig: null, + }); + }, + ); test.each(["", null])("rejects invalid integration type %s", async (type) => { - await expect(base44.asServiceRole.connectors.getConnection(type as unknown as string)).rejects.toThrow( - "Integration type is required and must be a string", - ); + await expect( + base44.asServiceRole.connectors.getConnection(type as unknown as string), + ).rejects.toThrow("Integration type is required and must be a string"); expect(platform.requests.count("connectors.getConnection")).toBe(0); }); + + test("isolates connections and service credentials by application", async () => { + const otherAppId = "other-connector-app"; + const otherServiceToken = "other-service-token"; + const otherClient = createClient({ + serverUrl: "https://base44.app", + appId: otherAppId, + serviceToken: otherServiceToken, + }); + const anonymousService = createClient({ + serverUrl: "https://base44.app", + appId, + }); + const wrongScope = createClient({ + serverUrl: "https://base44.app", + appId: otherAppId, + serviceToken, + }); + const userAsService = createClient({ + serverUrl: "https://base44.app", + appId, + serviceToken: userToken, + }); + platform.given + .app(otherAppId) + .auth.servicePrincipal(otherServiceToken, { id: "other-service" }); + platform.given.app(appId).auth.principal(userToken, { id: userId }); + platform.given.app(appId).connectors.connection("jira", "app-a-token"); + platform.given.app(otherAppId).connectors.connection("jira", "app-b-token"); + + await expect( + base44.asServiceRole.connectors.getConnection("jira"), + ).resolves.toMatchObject({ + accessToken: "app-a-token", + }); + await expect( + otherClient.asServiceRole.connectors.getConnection("jira"), + ).resolves.toMatchObject({ + accessToken: "app-b-token", + }); + await expect( + wrongScope.asServiceRole.connectors.getConnection("jira"), + ).rejects.toMatchObject({ status: 401 }); + await expect( + userAsService.asServiceRole.connectors.getConnection("jira"), + ).rejects.toMatchObject({ + status: 403, + message: "This endpoint is only accessible to service tokens", + }); + expect(() => anonymousService.asServiceRole).toThrow( + "Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.", + ); + otherClient.cleanup(); + anonymousService.cleanup(); + wrongScope.cleanup(); + userAsService.cleanup(); + }); }); describe("Connectors module – getWorkspaceConnection", () => { let base44: ReturnType; beforeEach(() => { - base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id", serviceToken: "service-token-123" }); + arrangeServicePrincipal(); + base44 = createClient({ + serverUrl: "https://base44.app", + appId, + serviceToken, + }); }); afterEach(() => base44.cleanup()); test("extracts accessToken and connectionConfig", async () => { - platform.given.connectors.workspaceConnection("connector-abc", "snowflake", "builder-oauth-token-xyz789", { subdomain: "xy12345.us-east-1" }); - await expect(base44.asServiceRole.connectors.getWorkspaceConnection("connector-abc")).resolves.toEqual({ - accessToken: "builder-oauth-token-xyz789", connectionConfig: { subdomain: "xy12345.us-east-1" }, + platform.given + .app(appId) + .connectors.workspaceConnection( + "connector-abc", + "snowflake", + "builder-oauth-token-xyz789", + { subdomain: "xy12345.us-east-1" }, + ); + await expect( + base44.asServiceRole.connectors.getWorkspaceConnection("connector-abc"), + ).resolves.toEqual({ + accessToken: "builder-oauth-token-xyz789", + connectionConfig: { subdomain: "xy12345.us-east-1" }, }); }); test("returns null when connection_config is omitted", async () => { - platform.given.connectors.workspaceConnection("conn-2", "databricks", "token-only"); - await expect(base44.asServiceRole.connectors.getWorkspaceConnection("conn-2")).resolves.toEqual({ - accessToken: "token-only", connectionConfig: null, + platform.given + .app(appId) + .connectors.workspaceConnection("conn-2", "databricks", "token-only"); + await expect( + base44.asServiceRole.connectors.getWorkspaceConnection("conn-2"), + ).resolves.toEqual({ + accessToken: "token-only", + connectionConfig: null, }); }); + test("isolates the same workspace connector ID between applications", async () => { + const otherAppId = "other-workspace-app"; + const otherServiceToken = "other-workspace-service"; + platform.given + .app(otherAppId) + .auth.servicePrincipal(otherServiceToken, { id: "other-service" }); + platform.given + .app(appId) + .connectors.workspaceConnection("shared-id", "snowflake", "app-a-token"); + platform.given + .app(otherAppId) + .connectors.workspaceConnection("shared-id", "snowflake", "app-b-token"); + const otherClient = createClient({ + serverUrl: "https://base44.app", + appId: otherAppId, + serviceToken: otherServiceToken, + }); + const userAsService = createClient({ + serverUrl: "https://base44.app", + appId, + serviceToken: userToken, + }); + platform.given.app(appId).auth.principal(userToken, { id: userId }); + + await expect( + base44.asServiceRole.connectors.getWorkspaceConnection("shared-id"), + ).resolves.toMatchObject({ accessToken: "app-a-token" }); + await expect( + otherClient.asServiceRole.connectors.getWorkspaceConnection("shared-id"), + ).resolves.toMatchObject({ accessToken: "app-b-token" }); + await expect( + userAsService.asServiceRole.connectors.getWorkspaceConnection( + "shared-id", + ), + ).rejects.toMatchObject({ + status: 403, + message: "This endpoint is only accessible to service tokens", + }); + otherClient.cleanup(); + userAsService.cleanup(); + }); + test.each(["", null])("rejects invalid connector ID %s", async (id) => { - await expect(base44.asServiceRole.connectors.getWorkspaceConnection(id as unknown as string)).rejects.toThrow( - "Connector ID is required and must be a string", - ); + await expect( + base44.asServiceRole.connectors.getWorkspaceConnection( + id as unknown as string, + ), + ).rejects.toThrow("Connector ID is required and must be a string"); }); }); describe("Connectors module – getCurrentAppUserConnection", () => { let base44: ReturnType; beforeEach(() => { - base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id", serviceToken: "service-token-123" }); + arrangeServicePrincipal(); + platform.given.app(appId).auth.principal(userToken, { id: userId }); + base44 = createClient({ + serverUrl: "https://base44.app", + appId, + token: userToken, + serviceToken, + }); }); afterEach(() => base44.cleanup()); test("extracts accessToken and connectionConfig", async () => { - platform.given.connectors.appUserConnection("connector-1", "jira", "user-oauth-token-abc123", { subdomain: "my-company" }); - await expect(base44.asServiceRole.connectors.getCurrentAppUserConnection("connector-1")).resolves.toEqual({ - accessToken: "user-oauth-token-abc123", connectionConfig: { subdomain: "my-company" }, + platform.given + .app(appId) + .connectors.appUserConnection( + userId, + "connector-1", + "jira", + "user-oauth-token-abc123", + { subdomain: "my-company" }, + ); + await expect( + base44.asServiceRole.connectors.getCurrentAppUserConnection( + "connector-1", + ), + ).resolves.toEqual({ + accessToken: "user-oauth-token-abc123", + connectionConfig: { subdomain: "my-company" }, }); + const userAsService = createClient({ + serverUrl: "https://base44.app", + appId, + token: userToken, + serviceToken: userToken, + }); + await expect( + userAsService.asServiceRole.connectors.getCurrentAppUserConnection( + "connector-1", + ), + ).rejects.toMatchObject({ + status: 403, + message: "This endpoint is only accessible to service tokens", + }); + userAsService.cleanup(); }); test.each([ ["connector-2", "slack", undefined], ["connector-3", "github", null], ])("returns null config for %s", async (id, type, config) => { - platform.given.connectors.appUserConnection(id, type, "user-token-only", config); - await expect(base44.asServiceRole.connectors.getCurrentAppUserConnection(id)).resolves.toEqual({ - accessToken: "user-token-only", connectionConfig: null, + platform.given + .app(appId) + .connectors.appUserConnection( + userId, + id, + type, + "user-token-only", + config, + ); + await expect( + base44.asServiceRole.connectors.getCurrentAppUserConnection(id), + ).resolves.toEqual({ + accessToken: "user-token-only", + connectionConfig: null, }); }); test.each(["", null])("rejects invalid connector ID %s", async (id) => { - await expect(base44.asServiceRole.connectors.getCurrentAppUserConnection(id as unknown as string)).rejects.toThrow( - "Connector ID is required and must be a string", + await expect( + base44.asServiceRole.connectors.getCurrentAppUserConnection( + id as unknown as string, + ), + ).rejects.toThrow("Connector ID is required and must be a string"); + }); + + test("connects and disconnects only the current app user", async () => { + platform.given + .app(appId) + .connectors.appUserAuthorization( + userId, + "connector-1", + "https://oauth.example/authorize", + ); + platform.given + .app(appId) + .connectors.appUserConnection( + userId, + "connector-1", + "jira", + "user-oauth-token", + ); + await expect(base44.connectors.connectAppUser("connector-1")).resolves.toBe( + "https://oauth.example/authorize", ); + await expect( + base44.connectors.disconnectAppUser("connector-1"), + ).resolves.toBeUndefined(); + await expect( + base44.asServiceRole.connectors.getCurrentAppUserConnection( + "connector-1", + ), + ).rejects.toMatchObject({ status: 404 }); + }); + + test("isolates app-user connections by on-behalf-of principal", async () => { + const otherUserToken = "other-user-token"; + const otherUserId = "other-user"; + platform.given + .app(appId) + .auth.principal(otherUserToken, { id: otherUserId }); + platform.given + .app(appId) + .connectors.appUserConnection( + userId, + "connector-1", + "jira", + "user-a-token", + ); + platform.given + .app(appId) + .connectors.appUserConnection( + otherUserId, + "connector-1", + "jira", + "user-b-token", + ); + const otherUser = createClient({ + serverUrl: "https://base44.app", + appId, + token: otherUserToken, + serviceToken, + }); + const noUser = createClient({ + serverUrl: "https://base44.app", + appId, + serviceToken, + }); + + await expect( + base44.asServiceRole.connectors.getCurrentAppUserConnection( + "connector-1", + ), + ).resolves.toMatchObject({ accessToken: "user-a-token" }); + await expect( + otherUser.asServiceRole.connectors.getCurrentAppUserConnection( + "connector-1", + ), + ).resolves.toMatchObject({ accessToken: "user-b-token" }); + await expect( + noUser.asServiceRole.connectors.getCurrentAppUserConnection( + "connector-1", + ), + ).rejects.toMatchObject({ status: 401 }); + otherUser.cleanup(); + noUser.cleanup(); }); }); diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts index ab0dc1d3..09562036 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -4,21 +4,29 @@ import { platform } from "../mocks/platform/index.ts"; describe("Custom Integrations Module", () => { let base44: ReturnType; + const appId = "test-app-id"; beforeEach(() => { - base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id" }); + platform.given.app(appId).workspace("workspace-a"); + base44 = createClient({ serverUrl: "https://base44.app", appId }); }); afterEach(() => base44.cleanup()); test("converts camelCase params to snake_case for the backend", async () => { const operationId = "get:/repos/{owner}/{repo}/issues"; - platform.given.customIntegrations.operation("github", operationId, { - issues: [{ id: 1, title: "Test Issue" }], - }); - const result = await base44.integrations.custom.call("github", operationId, { - payload: { title: "Test Issue" }, - pathParams: { owner: "testuser", repo: "testrepo" }, - queryParams: { state: "open" }, - }); + platform.given + .app(appId) + .customIntegrations.operation("github", operationId, { + issues: [{ id: 1, title: "Test Issue" }], + }); + const result = await base44.integrations.custom.call( + "github", + operationId, + { + payload: { title: "Test Issue" }, + pathParams: { owner: "testuser", repo: "testrepo" }, + queryParams: { state: "open" }, + }, + ); expect(result).toMatchObject({ success: true, status_code: 200 }); expect(result.data.issues).toHaveLength(1); expect(platform.requests.last("customIntegrations.call").body).toEqual({ @@ -29,36 +37,61 @@ describe("Custom Integrations Module", () => { }); test("works with empty params", async () => { - platform.given.customIntegrations.operation("github", "getAuthenticatedUser", { login: "testuser", id: 123 }); - const result = await base44.integrations.custom.call("github", "getAuthenticatedUser"); + platform.given + .app(appId) + .customIntegrations.operation("github", "getAuthenticatedUser", { + login: "testuser", + id: 123, + }); + const result = await base44.integrations.custom.call( + "github", + "getAuthenticatedUser", + ); expect(result.data.login).toBe("testuser"); expect(platform.requests.last("customIntegrations.call").body).toEqual({}); }); test("maps missing integration to a 404 Base44Error", async () => { - await expect(base44.integrations.custom.call("nonexistent", "someEndpoint")).rejects.toMatchObject({ - status: 404, name: "Base44Error", message: "Custom integration 'nonexistent' not found in workspace", + await expect( + base44.integrations.custom.call("nonexistent", "someEndpoint"), + ).rejects.toMatchObject({ + status: 404, + name: "Base44Error", + message: "Custom integration 'nonexistent' not found in workspace", }); }); test("maps missing operation to a 404 Base44Error", async () => { - platform.given.customIntegrations.operation("github", "existingOperation", {}); - await expect(base44.integrations.custom.call("github", "nonExistentOperation")).rejects.toMatchObject({ - status: 404, name: "Base44Error", - message: "Operation 'nonExistentOperation' not found in integration 'github'", + platform.given + .app(appId) + .customIntegrations.operation("github", "existingOperation", {}); + await expect( + base44.integrations.custom.call("github", "nonExistentOperation"), + ).rejects.toMatchObject({ + status: 404, + name: "Base44Error", + message: + "Operation 'nonExistentOperation' not found in integration 'github'", }); }); - test("returns the current backend envelope for an upstream-unavailable fault", async () => { + test("rejects a Base44 connection failure while preserving later recovery", async () => { const operationId = "get:/repos/{owner}/{repo}/issues"; - platform.given.customIntegrations.operation("github", operationId, { issues: [] }); - platform.given.faults.customIntegrations.upstreamUnavailable("github", operationId); - await expect(base44.integrations.custom.call("github", operationId)).resolves.toEqual({ - success: false, - status_code: 502, - data: { detail: "Failed to connect to external API: Connection refused" }, + platform.given + .app(appId) + .customIntegrations.operation("github", operationId, { issues: [] }); + platform.given + .app(appId) + .faults.customIntegrations.upstreamUnavailable("github", operationId); + await expect( + base44.integrations.custom.call("github", operationId), + ).rejects.toMatchObject({ + status: 502, + message: "Failed to connect to external API: Connection refused", }); - await expect(base44.integrations.custom.call("github", operationId)).resolves.toEqual({ + await expect( + base44.integrations.custom.call("github", operationId), + ).resolves.toEqual({ success: true, status_code: 200, data: { issues: [] }, @@ -72,57 +105,142 @@ describe("Custom Integrations Module", () => { [" ", "get", "Integration slug is required and cannot be empty"], ["github", "", "Operation ID is required and cannot be empty"], ["github", " \t\n ", "Operation ID is required and cannot be empty"], - ])("validates slug and operation ID (%s, %s)", async (slug, operationId, message) => { - await expect( - // @ts-expect-error Deliberately exercising invalid runtime input. - base44.integrations.custom.call(slug, operationId), - ).rejects.toThrow(message); - expect(platform.requests.count("customIntegrations.call")).toBe(0); - }); + ])( + "validates slug and operation ID (%s, %s)", + async (slug, operationId, message) => { + await expect( + // @ts-expect-error Deliberately exercising invalid runtime input. + base44.integrations.custom.call(slug, operationId), + ).rejects.toThrow(message); + expect(platform.requests.count("customIntegrations.call")).toBe(0); + }, + ); test("handles large payloads without dropping data", async () => { const items = Array.from({ length: 1000 }, (_, id) => ({ - id, name: `Item ${id}`, description: "A".repeat(100), metadata: { key: `value_${id}` }, + id, + name: `Item ${id}`, + description: "A".repeat(100), + metadata: { key: `value_${id}` }, })); - platform.given.customIntegrations.operation("myapi", "bulkCreate", { created: 1000 }); - const result = await base44.integrations.custom.call("myapi", "bulkCreate", { payload: { items } }); + platform.given + .app(appId) + .customIntegrations.operation("myapi", "bulkCreate", { created: 1000 }); + const result = await base44.integrations.custom.call( + "myapi", + "bulkCreate", + { payload: { items } }, + ); expect(result.data.created).toBe(1000); - expect(platform.requests.last("customIntegrations.call").body).toEqual({ payload: { items } }); + expect(platform.requests.last("customIntegrations.call").body).toEqual({ + payload: { items }, + }); }); test("includes custom headers in the backend request body", async () => { const headers = { "X-Custom-Header": "custom-value" }; - platform.given.customIntegrations.operation("myapi", "getData", { result: "ok" }); + platform.given + .app(appId) + .customIntegrations.operation("myapi", "getData", { result: "ok" }); await base44.integrations.custom.call("myapi", "getData", { headers }); - expect(platform.requests.last("customIntegrations.call").body).toEqual({ headers }); + expect(platform.requests.last("customIntegrations.call").body).toEqual({ + headers, + }); }); test("passes through multiple headers", async () => { const headers = { - "X-API-Key": "secret-key-123", "X-Request-ID": "req-456", - "Accept-Language": "en-US", "X-Custom-Auth": "Bearer token123", + "X-API-Key": "secret-key-123", + "X-Request-ID": "req-456", + "Accept-Language": "en-US", + "X-Custom-Auth": "Bearer token123", }; - platform.given.customIntegrations.operation("myapi", "secureEndpoint", { authenticated: true }); - const result = await base44.integrations.custom.call("myapi", "secureEndpoint", { headers }); + platform.given + .app(appId) + .customIntegrations.operation("myapi", "secureEndpoint", { + authenticated: true, + }); + const result = await base44.integrations.custom.call( + "myapi", + "secureEndpoint", + { headers }, + ); expect(result.data.authenticated).toBe(true); - expect(platform.requests.last("customIntegrations.call").body).toEqual({ headers }); + expect(platform.requests.last("customIntegrations.call").body).toEqual({ + headers, + }); }); test("only includes defined params in body", async () => { const operationId = "get:/users/{username}"; - platform.given.customIntegrations.operation("github", operationId, { login: "octocat" }); - await base44.integrations.custom.call("github", operationId, { pathParams: { username: "octocat" } }); + platform.given + .app(appId) + .customIntegrations.operation("github", operationId, { + login: "octocat", + }); + await base44.integrations.custom.call("github", operationId, { + pathParams: { username: "octocat" }, + }); expect(platform.requests.last("customIntegrations.call").body).toEqual({ path_params: { username: "octocat" }, }); }); test("custom property does not interfere with other integration packages", async () => { - platform.given.integrations.emailDelivered(); + platform.given.app(appId).integrations.emailDelivered(); // Legacy SDK compatibility; current Apper has removed this route. - platform.given.integrations.packageSucceeds("SomePackage", "SomeEndpoint"); - await expect(base44.integrations.Core.SendEmail({ to: "test@example.com", subject: "Test", body: "Test body" })).resolves.toMatchObject({ success: true }); - await expect(base44.integrations.SomePackage.SomeEndpoint({ param: "value" })).resolves.toMatchObject({ success: true }); + platform.given + .app(appId) + .integrations.packageSucceeds("SomePackage", "SomeEndpoint"); + await expect( + base44.integrations.Core.SendEmail({ + to: "test@example.com", + subject: "Test", + body: "Test body", + }), + ).resolves.toMatchObject({ success: true }); + await expect( + base44.integrations.SomePackage.SomeEndpoint({ param: "value" }), + ).resolves.toMatchObject({ success: true }); expect(platform.requests.count("integrations.invoke")).toBe(2); }); + + test("custom operations isolate by workspace and share only across associated apps", async () => { + const isolatedAppId = "isolated-custom-app"; + const sharedAppId = "shared-custom-app"; + platform.given.app(isolatedAppId).workspace("workspace-b"); + platform.given.app(sharedAppId).workspace("workspace-a"); + platform.given + .app(appId) + .customIntegrations.operation("github", "whoami", { workspace: "a" }); + platform.given + .app(isolatedAppId) + .customIntegrations.operation("github", "whoami", { workspace: "b" }); + const isolated = createClient({ + serverUrl: "https://base44.app", + appId: isolatedAppId, + }); + const shared = createClient({ + serverUrl: "https://base44.app", + appId: sharedAppId, + }); + + await expect( + base44.integrations.custom.call("github", "whoami"), + ).resolves.toMatchObject({ + data: { workspace: "a" }, + }); + await expect( + isolated.integrations.custom.call("github", "whoami"), + ).resolves.toMatchObject({ + data: { workspace: "b" }, + }); + await expect( + shared.integrations.custom.call("github", "whoami"), + ).resolves.toMatchObject({ + data: { workspace: "a" }, + }); + isolated.cleanup(); + shared.cleanup(); + }); }); diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts index 8f27f40c..435d417b 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -64,6 +64,50 @@ describe("Entities Module", () => { ).resolves.toEqual([{ id: "1", title: "Projected" }]); }); + test("list() sorts numeric fields numerically in both directions", async () => { + platform.given.app(appId).entities.records("Todo", [ + { id: "1", title: "Ten", completed: false, view_count: 10 }, + { id: "2", title: "Two", completed: false, view_count: 2 }, + { id: "3", title: "Thirty", completed: false, view_count: 30 }, + ]); + + await expect( + base44.entities.Todo.list("view_count"), + ).resolves.toMatchObject([ + { view_count: 2 }, + { view_count: 10 }, + { view_count: 30 }, + ]); + await expect( + base44.entities.Todo.list("-view_count"), + ).resolves.toMatchObject([ + { view_count: 30 }, + { view_count: 10 }, + { view_count: 2 }, + ]); + }); + + test("list() applies pagination after numeric sorting", async () => { + platform.given.app(appId).entities.records("Todo", [ + { id: "1", title: "Ten", completed: false, view_count: 10 }, + { id: "2", title: "Two", completed: false, view_count: 2 }, + { id: "3", title: "Thirty", completed: false, view_count: 30 }, + { id: "4", title: "Twenty", completed: false, view_count: 20 }, + ]); + + await expect( + base44.entities.Todo.list("view_count", 2, 1), + ).resolves.toMatchObject([ + { id: "1", view_count: 10 }, + { id: "4", view_count: 20 }, + ]); + expect(platform.requests.last("entities.list").query).toMatchObject({ + limit: "2", + skip: "1", + sort: "view_count", + }); + }); + test("filter() sends the query and returns matching domain state", async () => { platform.given.app(appId).entities.records("Todo", [ { id: "1", title: "Task 1", completed: false }, @@ -152,11 +196,20 @@ describe("Entities Module", () => { title: "Only A", completed: false, }); + const createdB = await otherClient.entities.Todo.create({ + title: "Only B", + completed: true, + }); expect(createdA.id).toBe("8"); + expect(createdB.id).toBe("8"); await expect(base44.entities.Todo.list()).resolves.toHaveLength(2); await expect(otherClient.entities.Todo.list()).resolves.toEqual([ { id: "7", title: "App B", completed: true }, + createdB, ]); + await expect(base44.entities.Todo.list()).resolves.not.toContainEqual( + createdB, + ); otherClient.cleanup(); }); diff --git a/tests/unit/integrations.test.js b/tests/unit/integrations.test.js index fbdeea7e..17c510eb 100644 --- a/tests/unit/integrations.test.js +++ b/tests/unit/integrations.test.js @@ -13,12 +13,17 @@ describe("Integrations Module", () => { afterEach(() => base44.cleanup()); test("Core integration sends named parameters to the endpoint", async () => { - platform.given.integrations.emailDelivered("123456"); - const email = { to: "test@example.com", subject: "Test Email", body: "This is a test email" }; + platform.given.app(appId).integrations.emailDelivered("123456"); + const email = { + to: "test@example.com", + subject: "Test Email", + body: "This is a test email", + }; const result = await base44.integrations.Core.SendEmail(email); expect(result).toEqual({ success: true, messageId: "123456" }); expect(platform.requests.last("integrations.invoke")).toMatchObject({ - method: "POST", body: email, + method: "POST", + body: email, url: `${serverUrl}/api/apps/${appId}/integration-endpoints/Core/SendEmail`, }); }); @@ -26,27 +31,44 @@ describe("Integrations Module", () => { test("Legacy custom package integration sends requests to its installable endpoint", async () => { // Kept for the SDK's backwards-compatible dynamic package API. Current Apper // no longer exposes installable-package integrations. - platform.given.integrations.packageSucceeds("CustomPackage", "CustomEndpoint", { result: "custom result" }); + platform.given + .app(appId) + .integrations.packageSucceeds("CustomPackage", "CustomEndpoint", { + result: "custom result", + }); const params = { param1: "value1", param2: "value2" }; - const result = await base44.integrations.CustomPackage.CustomEndpoint(params); + const result = + await base44.integrations.CustomPackage.CustomEndpoint(params); expect(result).toEqual({ success: true, result: "custom result" }); expect(platform.requests.last("integrations.invoke")).toMatchObject({ - method: "POST", body: params, + method: "POST", + body: params, url: `${serverUrl}/api/apps/${appId}/integration-endpoints/installable/CustomPackage/integration-endpoints/CustomEndpoint`, }); }); test("Integration serializes file uploads as multipart data", async () => { - platform.given.integrations.fileUploaded("file123"); + platform.given.app(appId).integrations.fileUploaded("file123"); const file = new File(["file content"], "test.txt", { type: "text/plain" }); - const result = await base44.integrations.Core.UploadFile({ file, metadata: { type: "document" } }); + const result = await base44.integrations.Core.UploadFile({ + file, + metadata: { type: "document" }, + }); expect(result).toEqual({ success: true, fileId: "file123" }); expect(platform.requests.last("integrations.invoke")).toMatchObject({ method: "POST", body: { type: "multipart", entries: expect.arrayContaining([ - { name: "file", file: { name: "test.txt", type: "text/plain", size: 12, bytes: [...new TextEncoder().encode("file content")] } }, + { + name: "file", + file: { + name: "test.txt", + type: "text/plain", + size: 12, + bytes: [...new TextEncoder().encode("file content")], + }, + }, { name: "metadata", value: '{"type":"document"}' }, ]), }, @@ -54,21 +76,51 @@ describe("Integrations Module", () => { }); test("Integration rejects string parameters before making a request", async () => { - await expect(base44.integrations.Core.SendEmail("invalid string parameter")).rejects.toThrow( + await expect( + base44.integrations.Core.SendEmail("invalid string parameter"), + ).rejects.toThrow( "Integration SendEmail must receive an object with named parameters", ); expect(platform.requests.count("integrations.invoke")).toBe(0); }); test("Integration maps a named invalid-parameters platform fault", async () => { - platform.given.integrations.emailDelivered("after-retry"); - platform.given.faults.integrations.invalidParameters("Core", "SendEmail"); - await expect(base44.integrations.Core.SendEmail({ invalid: "params" })).rejects.toMatchObject({ - status: 400, name: "Base44Error", message: "Invalid parameters", code: "INVALID_PARAMS", + platform.given.app(appId).integrations.emailDelivered("after-retry"); + platform.given + .app(appId) + .faults.integrations.invalidParameters("Core", "SendEmail"); + await expect( + base44.integrations.Core.SendEmail({ invalid: "params" }), + ).rejects.toMatchObject({ + status: 400, + name: "Base44Error", + message: "Invalid parameters", + code: "INVALID_PARAMS", }); - await expect(base44.integrations.Core.SendEmail({ to: "valid@example.com" })).resolves.toEqual({ + await expect( + base44.integrations.Core.SendEmail({ to: "valid@example.com" }), + ).resolves.toEqual({ success: true, messageId: "after-retry", }); }); + + test("Core integration behavior is isolated by application", async () => { + const otherAppId = "other-integration-app"; + const otherClient = createClient({ serverUrl, appId: otherAppId }); + platform.given.app(appId).integrations.emailDelivered("app-a-message"); + platform.given.app(otherAppId).integrations.emailDelivered("app-b-message"); + + await expect( + base44.integrations.Core.SendEmail({ to: "a@example.com" }), + ).resolves.toMatchObject({ + messageId: "app-a-message", + }); + await expect( + otherClient.integrations.Core.SendEmail({ to: "b@example.com" }), + ).resolves.toMatchObject({ + messageId: "app-b-message", + }); + otherClient.cleanup(); + }); }); diff --git a/tests/unit/integrations.test.ts b/tests/unit/integrations.test.ts index 9ffecea1..096930ab 100644 --- a/tests/unit/integrations.test.ts +++ b/tests/unit/integrations.test.ts @@ -4,34 +4,48 @@ import { platform } from "../mocks/platform/index.ts"; describe("Core Integrations - InvokeLLM", () => { let base44: ReturnType; + const appId = "test-app-id"; beforeEach(() => { - base44 = createClient({ serverUrl: "https://base44.app", appId: "test-app-id" }); + base44 = createClient({ serverUrl: "https://base44.app", appId }); }); afterEach(() => base44.cleanup()); test("passes model parameter to the API", async () => { - platform.given.integrations.llmResponds("Quantum computing uses qubits..."); + platform.given + .app(appId) + .integrations.llmResponds("Quantum computing uses qubits..."); const params = { prompt: "Explain quantum computing", model: "gpt_5" }; - await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe("Quantum computing uses qubits..."); + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe( + "Quantum computing uses qubits...", + ); expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); test("works without model parameter", async () => { - platform.given.integrations.llmResponds("Quantum computing uses qubits..."); + platform.given + .app(appId) + .integrations.llmResponds("Quantum computing uses qubits..."); const params = { prompt: "Explain quantum computing" }; - await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe("Quantum computing uses qubits..."); + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe( + "Quantum computing uses qubits...", + ); expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); test("passes model alongside other optional parameters", async () => { const response = { sentiment: "positive" }; - platform.given.integrations.llmResponds(response); + platform.given.app(appId).integrations.llmResponds(response); const params = { prompt: "Analyze this text", model: "claude_sonnet_4_6" as const, - response_json_schema: { type: "object", properties: { sentiment: { type: "string" } } }, + response_json_schema: { + type: "object", + properties: { sentiment: { type: "string" } }, + }, }; - await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toEqual(response); + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toEqual( + response, + ); expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); }); From a5d60853f24330c51ae53ec0e9f20f2b2880337f Mon Sep 17 00:00:00 2001 From: base44-os-gremlins Bot Date: Thu, 10 Sep 2026 08:57:47 +0000 Subject: [PATCH 9/9] test: model integration and connector domains --- tests/mocks/platform/actors.ts | 91 +++++--- tests/mocks/platform/app.ts | 59 ++--- tests/mocks/platform/auth.ts | 20 +- tests/mocks/platform/connectors.ts | 205 ++++++++++++++++-- tests/mocks/platform/entities.ts | 11 +- tests/mocks/platform/index.ts | 20 +- tests/mocks/platform/integrations.ts | 155 +++++++++++-- tests/mocks/platform/sso.ts | 59 +++-- tests/mocks/platform/state.ts | 65 ++++-- tests/unit/actors.test.ts | 38 ++-- tests/unit/app.test.ts | 18 +- tests/unit/auth-registration.test.ts | 8 +- tests/unit/connectors-proxy.test.ts | 109 ++++++---- tests/unit/custom-integrations.test.ts | 68 +++--- tests/unit/entities.test.ts | 28 +++ tests/unit/integrations.test.js | 6 +- tests/unit/integrations.test.ts | 19 +- tests/unit/mock-platform-architecture.test.ts | 145 ++++++++++++- tests/unit/sso.test.ts | 22 +- 19 files changed, 851 insertions(+), 295 deletions(-) diff --git a/tests/mocks/platform/actors.ts b/tests/mocks/platform/actors.ts index c2192a74..2d7ca228 100644 --- a/tests/mocks/platform/actors.ts +++ b/tests/mocks/platform/actors.ts @@ -1,24 +1,28 @@ import { http, HttpResponse } from "msw"; import { recordRequest } from "./state"; -interface ActorConfig { - websocketUrl: string; - token: string; - expiresAt: string; +interface ActorDeployment { + scriptId: string; + websocketHost: string; + issuedToken: string; + tokenExpiresAt: string; mode: "prod" | "preview"; } type ActorFault = "legacy-conflict" | "endpoint-unsupported" | "mint-failed"; -const actors = new Map(); +const actors = new Map(); const faults = new Map(); +const scoped = (appId: string, name: string) => `${appId}\u0000${name}`; -export const actorFixtures = { - available(name: string, config: ActorConfig) { - actors.set(name, structuredClone(config)); - }, - fault(name: string, fault: ActorFault) { - faults.set(name, fault); - }, -}; +export function actorFixturesFor(appId: string) { + return { + deployed(name: string, deployment: ActorDeployment) { + actors.set(scoped(appId, name), structuredClone(deployment)); + }, + fault(name: string, fault: ActorFault) { + faults.set(scoped(appId, name), fault); + }, + }; +} export function resetActorState() { actors.clear(); @@ -26,24 +30,45 @@ export function resetActorState() { } export const actorHandlers = [ - http.post("*/api/apps/:appId/actors/:actorName/connection-token", async ({ params, request }) => { - await recordRequest("actors.mintConnectionToken", request); - const name = String(params.actorName); - const fault = faults.get(name); - if (fault === "legacy-conflict") - return HttpResponse.json({ message: "Actor must be migrated before connecting directly" }, { status: 409 }); - if (fault === "endpoint-unsupported") - return HttpResponse.json({ error_type: "HTTPException", message: "Method Not Allowed", detail: "Method Not Allowed" }, { status: 405 }); - if (fault === "mint-failed") - return HttpResponse.json({ message: "mint exploded" }, { status: 500 }); - const config = actors.get(name); - return config - ? HttpResponse.json({ - websocket_url: config.websocketUrl, - token: config.token, - expires_at: config.expiresAt, - mode: config.mode, - }) - : HttpResponse.json({ detail: "Actor not found", code: "NOT_FOUND" }, { status: 404 }); - }), + http.post( + "*/api/apps/:appId/actors/:actorName/connection-token", + async ({ params, request }) => { + const recorded = await recordRequest( + "actors.mintConnectionToken", + request, + ); + const appId = String(params.appId); + const name = String(params.actorName); + const fault = faults.get(scoped(appId, name)); + if (fault === "legacy-conflict") + return HttpResponse.json( + { message: "Actor must be migrated before connecting directly" }, + { status: 409 }, + ); + if (fault === "endpoint-unsupported") + return HttpResponse.json( + { + error_type: "HTTPException", + message: "Method Not Allowed", + detail: "Method Not Allowed", + }, + { status: 405 }, + ); + if (fault === "mint-failed") + return HttpResponse.json({ message: "mint exploded" }, { status: 500 }); + const deployment = actors.get(scoped(appId, name)); + const body = recorded.body as { room: string; connection_id: string }; + return deployment + ? HttpResponse.json({ + websocket_url: `${deployment.websocketHost}/v1/actors/${deployment.scriptId}/rooms/${encodeURIComponent(body.room)}?_pk=${encodeURIComponent(body.connection_id)}`, + token: deployment.issuedToken, + expires_at: deployment.tokenExpiresAt, + mode: deployment.mode, + }) + : HttpResponse.json( + { detail: "Actor not found", code: "NOT_FOUND" }, + { status: 404 }, + ); + }, + ), ]; diff --git a/tests/mocks/platform/app.ts b/tests/mocks/platform/app.ts index 1ea03bd3..ff4b2aa4 100644 --- a/tests/mocks/platform/app.ts +++ b/tests/mocks/platform/app.ts @@ -1,37 +1,46 @@ import { http, HttpResponse } from "msw"; import { recordRequest } from "./state"; -type PublicSettings = { id: string; public_settings: string }; - -const settings = new Map(); +const accessPolicies = new Map(); const accessFaults = new Map(); -export const appFixtures = { - publicSettings(value: PublicSettings) { - settings.set(value.id, structuredClone(value)); - }, - /** Legacy edge response retained to verify SDK error compatibility; current - * apper's deployment settings route documents 404/500 instead. */ - legacyAccessDenied(appId: string, reason: "auth_required" | "user_not_registered") { - accessFaults.set(appId, reason); - }, -}; +export function appFixturesFor(appId: string) { + return { + deploymentAccess(policy: string) { + accessPolicies.set(appId, policy); + }, + /** Legacy edge response retained to verify SDK error compatibility; current + * apper's deployment settings route documents 404/500 instead. */ + legacyAccessDenied(reason: "auth_required" | "user_not_registered") { + accessFaults.set(appId, reason); + }, + }; +} export function resetAppState() { - settings.clear(); + accessPolicies.clear(); accessFaults.clear(); } export const appHandlers = [ - http.get("*/api/apps/public/prod/public-settings/by-id/:appId", async ({ params, request }) => { - await recordRequest("app.getPublicSettings", request); - const appId = String(params.appId); - const reason = accessFaults.get(appId); - if (reason) - return HttpResponse.json({ extra_data: { app_id: appId, reason } }, { status: 403 }); - const value = settings.get(appId); - return value - ? HttpResponse.json({ id: value.id, public_settings: value.public_settings }) - : HttpResponse.json({ detail: "App not found", code: "NOT_FOUND" }, { status: 404 }); - }), + http.get( + "*/api/apps/public/prod/public-settings/by-id/:appId", + async ({ params, request }) => { + await recordRequest("app.getPublicSettings", request); + const appId = String(params.appId); + const reason = accessFaults.get(appId); + if (reason) + return HttpResponse.json( + { extra_data: { app_id: appId, reason } }, + { status: 403 }, + ); + const policy = accessPolicies.get(appId); + return policy + ? HttpResponse.json({ id: appId, public_settings: policy }) + : HttpResponse.json( + { detail: "App not found", code: "NOT_FOUND" }, + { status: 404 }, + ); + }, + ), ]; diff --git a/tests/mocks/platform/auth.ts b/tests/mocks/platform/auth.ts index aac3dfd8..19f6eed3 100644 --- a/tests/mocks/platform/auth.ts +++ b/tests/mocks/platform/auth.ts @@ -106,9 +106,6 @@ export function authFixturesFor(appId: string) { meLatency(token: string, delayMs: number) { meLatencies.set(scoped(appId, token), delayMs); }, - passwordResetRequest(email: string, message = "Request accepted") { - state.passwordResetRequestMessages.set(scoped(appId, email), message); - }, resetToken( resetToken: string, email: string, @@ -245,23 +242,18 @@ export const authHandlers = [ { status: 404 }, ); return HttpResponse.json({ - id: registration.id, - message: registration.message, - otp_expires_in_minutes: registration.otpExpiresInMinutes, + id: registration.userId, + message: "Verification required", + otp_expires_in_minutes: registration.otpTtlMinutes, country_code: registration.countryCode, }); }), http.post( "*/api/apps/:appId/auth/reset-password-request", - async ({ params, request }) => { + async ({ request }) => { await recordRequest("auth.resetPasswordRequest", request); - const body = (await request.clone().json()) as { email: string }; - return HttpResponse.json({ - message: - state.passwordResetRequestMessages.get( - scoped(String(params.appId), body.email), - ) ?? "Request accepted", - }); + await request.clone().json(); + return HttpResponse.json({ message: "Request accepted" }); }, ), http.post( diff --git a/tests/mocks/platform/connectors.ts b/tests/mocks/platform/connectors.ts index 71fb6285..670a47cb 100644 --- a/tests/mocks/platform/connectors.ts +++ b/tests/mocks/platform/connectors.ts @@ -2,7 +2,7 @@ import { http, HttpResponse } from "msw"; import { recordRequest, state, - type ConnectorProxyOutcome, + type ConnectorProxyService, type ConnectorToken, type PlatformFault, } from "./state"; @@ -98,11 +98,30 @@ export function connectorFixturesFor(appId: string) { ) { appUserRedirectStore(appId, userId).set(connectorId, redirectUrl); }, - proxyOutcome(integrationType: string, outcome: ConnectorProxyOutcome) { - appStore(state.connectorProxyOutcomes, appId).set( - integrationType, - structuredClone(outcome), - ); + socialApi( + integrationType: string, + account: Record = { id: "mock-account" }, + ) { + appStore(state.connectorProxyServices, appId).set(integrationType, { + kind: "social", + account: structuredClone(account), + tweets: [], + nextTweetId: 1, + }); + }, + mapsApi( + integrationType: string, + staticMap: { bytes: number[]; contentType: string }, + ) { + appStore(state.connectorProxyServices, appId).set(integrationType, { + kind: "maps", + staticMap: structuredClone(staticMap), + }); + }, + echoApi(integrationType: string) { + appStore(state.connectorProxyServices, appId).set(integrationType, { + kind: "echo", + }); }, }; } @@ -123,6 +142,113 @@ export function connectorFaultFixturesFor(appId: string) { integrationType, }); }, + upstreamRejected(integrationType: string) { + state.faults.push({ + kind: "connector-upstream-rejected", + appId, + integrationType, + }); + }, + notSent(integrationType: string) { + state.faults.push({ kind: "connector-not-sent", appId, integrationType }); + }, + timedOut(integrationType: string) { + state.faults.push({ + kind: "connector-timed-out", + appId, + integrationType, + }); + }, + sentUnconfirmed(integrationType: string) { + state.faults.push({ + kind: "connector-sent-unconfirmed", + appId, + integrationType, + }); + }, + }; +} + +function proxyEnvelope( + service: ConnectorProxyService, + requestBody: Record, +) { + if (service.kind === "social") { + if (requestBody.method === "POST" && requestBody.path === "/2/tweets") { + if (typeof requestBody.body?.text !== "string") + return { + success: false, + phase: "responded", + status_code: 400, + data: { title: "Tweet text is required" }, + headers: {}, + credits_charged: 3, + }; + const tweet = { + id: String(service.nextTweetId++), + text: requestBody.body?.text, + }; + service.tweets.push(tweet); + return { + success: true, + phase: "responded", + status_code: 201, + data: { data: tweet }, + headers: { "x-rate-limit-remaining": "42" }, + credits_charged: 3, + }; + } + if (requestBody.path === "/2/tweets/search/recent") + return { + success: true, + phase: "responded", + status_code: 200, + data: { data: structuredClone(service.tweets) }, + headers: { "x-rate-limit-remaining": "42" }, + credits_charged: 3, + }; + if (requestBody.path === "/2/users/me" || requestBody.path === "/scope") + return { + success: true, + phase: "responded", + status_code: 200, + data: { data: structuredClone(service.account) }, + headers: { "x-rate-limit-remaining": "42" }, + credits_charged: 3, + }; + return { + success: false, + phase: "responded", + status_code: 404, + data: { title: "Upstream resource not found" }, + headers: {}, + credits_charged: 3, + }; + } + if (service.kind === "maps") { + const isStaticMap = String(requestBody.path).includes("staticmap"); + return { + success: true, + phase: "responded", + status_code: 200, + data: isStaticMap ? null : { location: "Mock place" }, + ...(isStaticMap + ? { + data_base64: btoa(String.fromCharCode(...service.staticMap.bytes)), + content_type: service.staticMap.contentType, + } + : {}), + headers: {}, + credits_charged: 1, + }; + } + return { + success: true, + phase: "responded", + status_code: 200, + data: { received: structuredClone(requestBody) }, + headers: {}, + credits_charged: 0, }; } @@ -221,7 +347,7 @@ export const connectorHandlers = [ http.post( "*/api/apps/:appId/connectors/:integrationType/call", async ({ params, request }) => { - await recordRequest("connectors.callApi", request); + const recorded = await recordRequest("connectors.callApi", request); const appId = String(params.appId); const principal = principalFor(appId, request)?.principal; if (!principal) return unauthorized(); @@ -242,28 +368,61 @@ export const connectorHandlers = [ }, { status: 402 }, ); - const outcome = state.connectorProxyOutcomes + const upstreamRejected = takeFault( + (item) => + item.kind === "connector-upstream-rejected" && + item.appId === appId && + item.integrationType === integrationType, + ); + if (upstreamRejected) + return HttpResponse.json({ + success: false, + phase: "responded", + status_code: 400, + data: { title: "Invalid Request" }, + headers: {}, + credits_charged: 3, + }); + const uncertain = state.faults.findIndex( + (item) => + [ + "connector-not-sent", + "connector-timed-out", + "connector-sent-unconfirmed", + ].includes(item.kind) && + "appId" in item && + item.appId === appId && + "integrationType" in item && + item.integrationType === integrationType, + ); + if (uncertain >= 0) { + const [fault] = state.faults.splice(uncertain, 1); + const phase = + fault.kind === "connector-not-sent" + ? "not_sent" + : fault.kind === "connector-timed-out" + ? "timed_out" + : "sent_unconfirmed"; + return HttpResponse.json({ + success: false, + phase, + status_code: null, + data: { error: "request outcome unknown" }, + headers: {}, + credits_charged: phase === "not_sent" ? 0 : 3, + }); + } + const service = state.connectorProxyServices .get(appId) ?.get(integrationType); - if (!outcome) + if (!service) return HttpResponse.json( { detail: "Connector proxy not configured", code: "NOT_FOUND" }, { status: 404 }, ); - return HttpResponse.json({ - success: outcome.success, - phase: outcome.phase, - status_code: outcome.status, - data: outcome.data, - ...(outcome.dataBase64 === undefined - ? {} - : { data_base64: outcome.dataBase64 }), - ...(outcome.contentType === undefined - ? {} - : { content_type: outcome.contentType }), - headers: outcome.headers ?? {}, - credits_charged: outcome.creditsCharged ?? 0, - }); + return HttpResponse.json( + proxyEnvelope(service, recorded.body as Record), + ); }, ), http.post( diff --git a/tests/mocks/platform/entities.ts b/tests/mocks/platform/entities.ts index 26b2b222..af147efe 100644 --- a/tests/mocks/platform/entities.ts +++ b/tests/mocks/platform/entities.ts @@ -34,11 +34,18 @@ function nextId(appId: string) { } function compareValues(left: unknown, right: unknown) { + const leftNullish = left == null; + const rightNullish = right == null; + if (leftNullish || rightNullish) { + // Apper groups Mongo missing and explicit-null values together. Their + // relative backend order is unspecified; returning zero preserves fixture + // insertion order and keeps this comparator anti-symmetric. + if (leftNullish && rightNullish) return 0; + return leftNullish ? -1 : 1; + } if (typeof left === "number" && typeof right === "number") return left - right; if (left === right) return 0; - if (left == null) return -1; - if (right == null) return 1; if (typeof left === "boolean" && typeof right === "boolean") return Number(left) - Number(right); const leftText = String(left); diff --git a/tests/mocks/platform/index.ts b/tests/mocks/platform/index.ts index 25395e16..e50cd566 100644 --- a/tests/mocks/platform/index.ts +++ b/tests/mocks/platform/index.ts @@ -1,7 +1,7 @@ -import { actorFixtures, actorHandlers, resetActorState } from "./actors"; +import { actorFixturesFor, actorHandlers, resetActorState } from "./actors"; import { agentHandlers } from "./agents"; import { analyticsHandlers } from "./analytics"; -import { appFixtures, appHandlers, resetAppState } from "./app"; +import { appFixturesFor, appHandlers, resetAppState } from "./app"; import { authFaultFixturesFor, authFixturesFor, @@ -23,7 +23,7 @@ import { integrationFixturesFor, integrationHandlers, } from "./integrations"; -import { resetSsoState, ssoFixtures, ssoHandlers } from "./sso"; +import { resetSsoState, ssoFixturesFor, ssoHandlers } from "./sso"; import { resetPlatformState, state, @@ -89,10 +89,16 @@ function forApp(appId: string) { const integrations = integrationFixturesFor(appId); const customIntegrations = customIntegrationFixturesFor(appId); const connectors = connectorFixturesFor(appId); + const deployment = appFixturesFor(appId); + const actors = actorFixturesFor(appId); + const sso = ssoFixturesFor(appId); return { workspace(workspaceId: string) { state.appWorkspaces.set(appId, workspaceId); }, + deployment, + actors, + sso, agents: { conversationsForUser( userId: string, @@ -137,7 +143,7 @@ function forApp(appId: string) { }, auth: { ...auth, - registration(email: string, registration: PlatformRegistration) { + registrationChallenge(email: string, registration: PlatformRegistration) { state.registrations.set(`${appId}\u0000${email}`, clone(registration)); }, }, @@ -279,19 +285,15 @@ function arrangeConversations( ); } -const appGiven = Object.assign(forApp, appFixtures); - export const platform = { reset, given: { - app: appGiven, + app: forApp, functions: { legacyEndpoint(functionPath: string) { state.legacyFunctions.add(functionPath.replace(/^\//, "")); }, }, - actors: actorFixtures, - sso: ssoFixtures, generic: genericFixtures, faults: {}, }, diff --git a/tests/mocks/platform/integrations.ts b/tests/mocks/platform/integrations.ts index 4a642ae0..f92f74ee 100644 --- a/tests/mocks/platform/integrations.ts +++ b/tests/mocks/platform/integrations.ts @@ -22,24 +22,21 @@ function integrationStore(appId: string) { export function integrationFixturesFor(appId: string) { return { - packageSucceeds( - packageName: string, - endpointName: string, - result: Record = {}, - ) { + legacyEndpoint(packageName: string, endpointName: string) { integrationStore(appId).set(endpointKey(packageName, endpointName), { - response: { success: true, ...result }, + kind: "legacy-endpoint", }); }, emailDelivered(messageId = "123456") { - this.packageSucceeds("Core", "SendEmail", { messageId }); + integrationStore(appId).set(endpointKey("Core", "SendEmail"), { + kind: "email-delivery", + nextId: messageId, + }); }, fileUploaded(fileId = "file123") { - this.packageSucceeds("Core", "UploadFile", { fileId }); - }, - llmResponds(response: unknown) { - integrationStore(appId).set(endpointKey("Core", "InvokeLLM"), { - response, + integrationStore(appId).set(endpointKey("Core", "UploadFile"), { + kind: "file-upload", + nextId: fileId, }); }, }; @@ -62,6 +59,7 @@ function invokeIntegration( appId: string, packageName: string, endpointName: string, + body: unknown, ) { const fault = takeFault( (item) => @@ -75,6 +73,14 @@ function invokeIntegration( { detail: "Invalid parameters", code: "INVALID_PARAMS" }, { status: 400 }, ); + // This deterministic transport contract does not simulate model behavior. + // It only proves that the SDK forwards LLM options and returns JSON/text. + if (packageName === "Core" && endpointName === "InvokeLLM") + return HttpResponse.json( + (body as Record)?.response_json_schema + ? { mock: true, kind: "structured" } + : "Mock LLM text response", + ); const endpoint = integrationStore(appId).get( endpointKey(packageName, endpointName), ); @@ -86,7 +92,14 @@ function invokeIntegration( }, { status: 404 }, ); - return HttpResponse.json(endpoint.response as any); + switch (endpoint.kind) { + case "email-delivery": + return HttpResponse.json({ success: true, messageId: endpoint.nextId }); + case "file-upload": + return HttpResponse.json({ success: true, fileId: endpoint.nextId }); + case "legacy-endpoint": + return HttpResponse.json({ success: true, received: body }); + } } function parseCustomRoute(request: Request) { @@ -124,20 +137,107 @@ function workspaceFor(appId: string) { export function customIntegrationFixturesFor(appId: string) { return { - operation( + githubRepository( slug: string, - operationId: string, - data: unknown, - statusCode = 200, + owner: string, + repository: string, + issues: Array>, ) { + workspaceOperations(workspaceFor(appId), slug).set( + "get:/repos/{owner}/{repo}/issues", + { + kind: "github-issues", + owner, + repository, + issues: structuredClone(issues), + }, + ); + }, + githubUser(slug: string, user: Record) { + const operations = workspaceOperations(workspaceFor(appId), slug); + operations.set("getAuthenticatedUser", { + kind: "github-user", + user: structuredClone(user), + }); + operations.set("get:/users/{username}", { + kind: "github-user", + user: structuredClone(user), + }); + }, + operationAvailable(slug: string, operationId: string) { workspaceOperations(workspaceFor(appId), slug).set(operationId, { - data, - statusCode, + kind: "available", + }); + }, + inventory(slug: string) { + const items: Array> = []; + const operations = workspaceOperations(workspaceFor(appId), slug); + operations.set("bulkCreate", { kind: "inventory", items }); + operations.set("listItems", { kind: "inventory", items }); + }, + requestInspector(slug: string) { + workspaceOperations(workspaceFor(appId), slug).set("getData", { + kind: "request-inspector", + }); + }, + apiKeyProtected(slug: string, apiKey: string) { + workspaceOperations(workspaceFor(appId), slug).set("secureEndpoint", { + kind: "api-key-protected", + apiKey, + }); + }, + workspaceIdentity(slug: string) { + workspaceOperations(workspaceFor(appId), slug).set("whoami", { + kind: "workspace-identity", }); }, }; } +function customOperationData( + workspaceId: string, + operationId: string, + operation: import("./state").CustomIntegrationOperation, + body: Record, +) { + switch (operation.kind) { + case "github-issues": + return { + issues: + body.path_params?.owner === operation.owner && + body.path_params?.repo === operation.repository + ? structuredClone(operation.issues).filter( + (issue) => + !body.query_params?.state || + issue.state === body.query_params.state, + ) + : [], + }; + case "github-user": + return structuredClone(operation.user); + case "available": + return { available: true }; + case "inventory": { + if (operationId === "bulkCreate") { + const incoming = Array.isArray(body.payload?.items) + ? body.payload.items + : []; + operation.items.push(...structuredClone(incoming)); + return { created: incoming.length, total: operation.items.length }; + } + return { items: structuredClone(operation.items) }; + } + case "request-inspector": + return { receivedHeaders: structuredClone(body.headers ?? {}) }; + case "api-key-protected": + return { + authenticated: body.headers?.["X-API-Key"] === operation.apiKey, + }; + case "workspace-identity": + return { workspaceId }; + } +} + export function customIntegrationFaultFixturesFor(appId: string) { return { upstreamUnavailable(slug: string, operationId: string) { @@ -155,11 +255,12 @@ export const integrationHandlers = [ http.post( "*/api/apps/:appId/integration-endpoints/Core/:endpointName", async ({ params, request }) => { - await recordRequest("integrations.invoke", request); + const recorded = await recordRequest("integrations.invoke", request); return invokeIntegration( String(params.appId), "Core", String(params.endpointName), + recorded.body, ); }, ), @@ -168,18 +269,19 @@ export const integrationHandlers = [ // integrations, but the SDK still promises this dynamic package route. "*/api/apps/:appId/integration-endpoints/installable/:packageName/integration-endpoints/:endpointName", async ({ params, request }) => { - await recordRequest("integrations.invoke", request); + const recorded = await recordRequest("integrations.invoke", request); return invokeIntegration( String(params.appId), String(params.packageName), String(params.endpointName), + recorded.body, ); }, ), http.post( /^https?:\/\/[^/]+\/api\/apps\/[^/]+\/integrations\/custom\/.+$/, async ({ request }) => { - await recordRequest("customIntegrations.call", request); + const recorded = await recordRequest("customIntegrations.call", request); const route = parseCustomRoute(request); if (!route) return HttpResponse.json( @@ -221,8 +323,13 @@ export const integrationHandlers = [ ); return HttpResponse.json({ success: true, - status_code: operation.statusCode, - data: operation.data, + status_code: 200, + data: customOperationData( + workspaceId, + operationId, + operation, + (recorded.body ?? {}) as Record, + ), }); }, ), diff --git a/tests/mocks/platform/sso.ts b/tests/mocks/platform/sso.ts index e36bd7f9..97b756b9 100644 --- a/tests/mocks/platform/sso.ts +++ b/tests/mocks/platform/sso.ts @@ -1,18 +1,31 @@ import { http, HttpResponse } from "msw"; import { recordRequest } from "./state"; -const idTokens = new Map(); -const accessTokens = new Map(); +const idTokens = new Map>(); +const accessTokens = new Map>(); // These legacy SDK routes were not found in apper d9ae151. Keep their // compatibility behavior centralized and explicitly avoid claiming fidelity. -export const ssoFixtures = { - tokens(userId: string, value: { idToken?: string; accessToken?: string }) { - if (value.idToken !== undefined) idTokens.set(userId, value.idToken); - if (value.accessToken !== undefined) accessTokens.set(userId, value.accessToken); - }, -}; +function appTokens(store: Map>, appId: string) { + let tokens = store.get(appId); + if (!tokens) { + tokens = new Map(); + store.set(appId, tokens); + } + return tokens; +} + +export function ssoFixturesFor(appId: string) { + return { + tokens(userId: string, value: { idToken?: string; accessToken?: string }) { + if (value.idToken !== undefined) + appTokens(idTokens, appId).set(userId, value.idToken); + if (value.accessToken !== undefined) + appTokens(accessTokens, appId).set(userId, value.accessToken); + }, + }; +} export function resetSsoState() { idTokens.clear(); @@ -20,16 +33,36 @@ export function resetSsoState() { } function tokenHandler(kind: "id" | "access") { - return async ({ params, request }: { params: Record; request: Request }) => { - await recordRequest(kind === "id" ? "sso.getIdToken" : "sso.getAccessToken", request); - const token = (kind === "id" ? idTokens : accessTokens).get(String(params.userId)); + return async ({ + params, + request, + }: { + params: Record; + request: Request; + }) => { + await recordRequest( + kind === "id" ? "sso.getIdToken" : "sso.getAccessToken", + request, + ); + const token = (kind === "id" ? idTokens : accessTokens) + .get(String(params.appId)) + ?.get(String(params.userId)); return token === undefined - ? HttpResponse.json({ detail: `No ${kind === "id" ? "ID" : "access"} token stored`, code: "NOT_FOUND" }, { status: 404 }) + ? HttpResponse.json( + { + detail: `No ${kind === "id" ? "ID" : "access"} token stored`, + code: "NOT_FOUND", + }, + { status: 404 }, + ) : HttpResponse.json(token); }; } export const ssoHandlers = [ http.get("*/api/apps/:appId/auth/sso/idtoken/:userId", tokenHandler("id")), - http.get("*/api/apps/:appId/auth/sso/accesstoken/:userId", tokenHandler("access")), + http.get( + "*/api/apps/:appId/auth/sso/accesstoken/:userId", + tokenHandler("access"), + ), ]; diff --git a/tests/mocks/platform/state.ts b/tests/mocks/platform/state.ts index 32b80a1d..73db0ef6 100644 --- a/tests/mocks/platform/state.ts +++ b/tests/mocks/platform/state.ts @@ -33,20 +33,29 @@ export interface StoredConversation { } export interface PlatformRegistration { - id: string; - message: string; - otpExpiresInMinutes: number; + userId: string; + otpTtlMinutes: number; countryCode: string | null; } export interface IntegrationEndpoint { - response: unknown; + kind: "email-delivery" | "file-upload" | "legacy-endpoint"; + nextId?: string; } -export interface CustomIntegrationOperation { - data: unknown; - statusCode: number; -} +export type CustomIntegrationOperation = + | { + kind: "github-issues"; + owner: string; + repository: string; + issues: Array>; + } + | { kind: "github-user"; user: Record } + | { kind: "available" } + | { kind: "inventory"; items: Array> } + | { kind: "request-inspector" } + | { kind: "api-key-protected"; apiKey: string } + | { kind: "workspace-identity" }; export interface ConnectorToken { accessToken: string; @@ -54,16 +63,18 @@ export interface ConnectorToken { connectionConfig?: Record | null; } -export interface ConnectorProxyOutcome { - success: boolean; - phase: "responded" | "not_sent" | "timed_out" | "sent_unconfirmed"; - status: number | null; - data: unknown; - dataBase64?: string | null; - contentType?: string | null; - headers?: Record; - creditsCharged?: number; -} +export type ConnectorProxyService = + | { + kind: "social"; + account: Record; + tweets: Array>; + nextTweetId: number; + } + | { + kind: "maps"; + staticMap: { bytes: number[]; contentType: string }; + } + | { kind: "echo" }; export interface FunctionInvocation { appId: string; @@ -100,6 +111,15 @@ export type PlatformFault = appId: string; integrationType: string; } + | { + kind: + | "connector-upstream-rejected" + | "connector-not-sent" + | "connector-timed-out" + | "connector-sent-unconfirmed"; + appId: string; + integrationType: string; + } | { kind: "auth-registration-rejected"; appId: string; email: string } | { kind: "function-internal-error"; appId: string; functionName: string } | { kind: "function-not-found"; appId: string; functionName: string } @@ -122,9 +142,8 @@ interface PlatformState { workspaceConnectorTokens: Map>; appUserConnectorTokens: Map>>; appUserConnectorRedirects: Map>>; - connectorProxyOutcomes: Map>; + connectorProxyServices: Map>; registrations: Map; - passwordResetRequestMessages: Map; functionBehaviors: Map>; legacyFunctions: Set; faults: PlatformFault[]; @@ -144,9 +163,8 @@ export const state: PlatformState = { workspaceConnectorTokens: new Map(), appUserConnectorTokens: new Map(), appUserConnectorRedirects: new Map(), - connectorProxyOutcomes: new Map(), + connectorProxyServices: new Map(), registrations: new Map(), - passwordResetRequestMessages: new Map(), functionBehaviors: new Map(), legacyFunctions: new Set(), faults: [], @@ -166,9 +184,8 @@ export function resetPlatformState() { state.workspaceConnectorTokens.clear(); state.appUserConnectorTokens.clear(); state.appUserConnectorRedirects.clear(); - state.connectorProxyOutcomes.clear(); + state.connectorProxyServices.clear(); state.registrations.clear(); - state.passwordResetRequestMessages.clear(); state.functionBehaviors.clear(); state.legacyFunctions.clear(); state.faults = []; diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index b6a6a6d8..d67a44d9 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -599,10 +599,11 @@ describe("Actors Module — client wiring", () => { }); test("mints via POST /connection-token with app, auth, and version headers", async () => { - platform.given.actors.available("PongGame", { - websocketUrl: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", - token: "jwt.min.ted", - expiresAt: "2026-01-01T00:00:00Z", + platform.given.app(appId).actors.deployed("PongGame", { + websocketHost: "wss://actors.example", + scriptId: "scr_1", + issuedToken: "jwt.min.ted", + tokenExpiresAt: "2026-01-01T00:00:00Z", mode: "preview", }); @@ -628,7 +629,7 @@ describe("Actors Module — client wiring", () => { }); test("a 409 mint reply falls back to the legacy proxy URL without calling onError", async () => { - platform.given.actors.fault("PongGame", "legacy-conflict"); + platform.given.app(appId).actors.fault("PongGame", "legacy-conflict"); const onError = vi.fn(); const base44 = createClient({ @@ -649,7 +650,7 @@ describe("Actors Module — client wiring", () => { test("a 405 mint reply (backend without the endpoint) falls back to the proxy", async () => { // What a pre-direct backend actually answers: its actor deploy routes // match the path via `{handler_name:path}` but not the POST method. - platform.given.actors.fault("PongGame", "endpoint-unsupported"); + platform.given.app(appId).actors.fault("PongGame", "endpoint-unsupported"); const onError = vi.fn(); const base44 = createClient({ @@ -667,7 +668,7 @@ describe("Actors Module — client wiring", () => { }); test("a non-fallback mint failure reaches the client's onError as a Base44Error", async () => { - platform.given.actors.fault("PongGame", "mint-failed"); + platform.given.app(appId).actors.fault("PongGame", "mint-failed"); const onError = vi.fn(); const base44 = createClient({ @@ -696,10 +697,11 @@ describe("Actors Module — client wiring", () => { vi.stubGlobal("document", undefined); vi.stubGlobal("localStorage", undefined); - platform.given.actors.available("PongGame", { - websocketUrl: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", - token: "jwt.min.ted", - expiresAt: "2026-01-01T00:00:00Z", + platform.given.app(appId).actors.deployed("PongGame", { + websocketHost: "wss://actors.example", + scriptId: "scr_1", + issuedToken: "jwt.min.ted", + tokenExpiresAt: "2026-01-01T00:00:00Z", mode: "preview", }); @@ -714,7 +716,10 @@ describe("Actors Module — client wiring", () => { expect(typeof seen[0]).toBe("string"); expect(seen[0]).toBeTruthy(); expect(seen[0]).toBe(seen[1]); // stable across reconnects, not a fresh id per call - expect((platform.requests.last("analytics.trackBatch").body as any).events[0].event_name).toBe("__initialization_event__"); + expect( + (platform.requests.last("analytics.trackBatch").body as any).events[0] + .event_name, + ).toBe("__initialization_event__"); base44.cleanup(); }); @@ -726,10 +731,11 @@ describe("Actors Module — client wiring", () => { vi.stubGlobal("document", undefined); vi.stubGlobal("localStorage", undefined); - platform.given.actors.available("PongGame", { - websocketUrl: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", - token: "jwt.min.ted", - expiresAt: "2026-01-01T00:00:00Z", + platform.given.app(appId).actors.deployed("PongGame", { + websocketHost: "wss://actors.example", + scriptId: "scr_1", + issuedToken: "jwt.min.ted", + tokenExpiresAt: "2026-01-01T00:00:00Z", mode: "preview", }); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index ce19a11f..3ec88e60 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -9,7 +9,9 @@ describe("App module", () => { let base44: ReturnType; beforeEach(() => { - platform.given.app.publicSettings({ id: appId, public_settings: "public_without_login" }); + platform.given + .app(appId) + .deployment.deploymentAccess("public_without_login"); base44 = createClient({ serverUrl, appId, token }); }); @@ -24,13 +26,17 @@ describe("App module", () => { test("getPublicSettings authenticates with the client's token, so callers never handle it", async () => { await base44.app.getPublicSettings(); - expect(platform.requests.last("app.getPublicSettings").headers.authorization).toBe(`Bearer ${token}`); + expect( + platform.requests.last("app.getPublicSettings").headers.authorization, + ).toBe(`Bearer ${token}`); }); test("getPublicSettings sends no Authorization header for an anonymous client", async () => { const anonymous = createClient({ serverUrl, appId }); await anonymous.app.getPublicSettings(); - expect(platform.requests.last("app.getPublicSettings").headers.authorization).toBeUndefined(); + expect( + platform.requests.last("app.getPublicSettings").headers.authorization, + ).toBeUndefined(); anonymous.cleanup(); }); @@ -40,8 +46,10 @@ describe("App module", () => { ] as const)( "getPublicSettings surfaces a 403 %s as a Base44Error carrying the reason", async (reason) => { - platform.given.app.legacyAccessDenied(appId, reason); - const error = await base44.app.getPublicSettings().catch((rejection) => rejection); + platform.given.app(appId).deployment.legacyAccessDenied(reason); + const error = await base44.app + .getPublicSettings() + .catch((rejection) => rejection); expect(error).toBeInstanceOf(Base44Error); expect(error.status).toBe(403); expect(error.data.extra_data.reason).toBe(reason); diff --git a/tests/unit/auth-registration.test.ts b/tests/unit/auth-registration.test.ts index 04f670df..cfabd8b4 100644 --- a/tests/unit/auth-registration.test.ts +++ b/tests/unit/auth-registration.test.ts @@ -17,10 +17,9 @@ describe("Auth registration and password recovery HTTP contracts", () => { turnstile_token: "challenge", referral_code: "referral", }; - platform.given.app(appId).auth.registration(payload.email, { - id: "new-user-id", - message: "Verification required", - otpExpiresInMinutes: 10, + platform.given.app(appId).auth.registrationChallenge(payload.email, { + userId: "new-user-id", + otpTtlMinutes: 10, countryCode: "US", }); expect(await client.auth.register(payload)).toEqual({ @@ -44,7 +43,6 @@ describe("Auth registration and password recovery HTTP contracts", () => { expect(platform.requests.last("auth.register").body).toEqual(payload); }); test("password reset request sends only the email", async () => { - platform.given.app(appId).auth.passwordResetRequest("reset@example.test"); expect( await client.auth.resetPasswordRequest("reset@example.test"), ).toEqual({ message: "Request accepted" }); diff --git a/tests/unit/connectors-proxy.test.ts b/tests/unit/connectors-proxy.test.ts index 0d5c34a4..80b4ac8a 100644 --- a/tests/unit/connectors-proxy.test.ts +++ b/tests/unit/connectors-proxy.test.ts @@ -2,14 +2,6 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { createClient } from "../../src/index.ts"; import { platform } from "../mocks/platform/index.ts"; -const responded = { - success: true, - phase: "responded" as const, - status: 201, - data: { data: { id: "1" } }, - headers: { "x-rate-limit-remaining": "42" }, - creditsCharged: 3, -}; const appId = "test-app-id"; const serviceToken = "service-token-123"; @@ -25,7 +17,7 @@ describe("Connectors module – metered connector proxy", () => { appId, serviceToken, }); - platform.given.app(appId).connectors.proxyOutcome("x", responded); + platform.given.app(appId).connectors.socialApi("x", { id: "account-a" }); }); afterEach(() => base44.cleanup()); @@ -69,9 +61,7 @@ describe("Connectors module – metered connector proxy", () => { }); test("percent-encodes the integration type so it stays on the connectors route", async () => { - platform.given - .app(appId) - .connectors.proxyOutcome("../evil/route", responded); + platform.given.app(appId).connectors.echoApi("../evil/route"); const result = await base44.asServiceRole.connectors.callApi( "../evil/route" as any, { path: "/x" }, @@ -83,7 +73,10 @@ describe("Connectors module – metered connector proxy", () => { }); test("forwards a named host, and omits it entirely when unset", async () => { - platform.given.app(appId).connectors.proxyOutcome("googlemaps", responded); + platform.given.app(appId).connectors.mapsApi("googlemaps", { + bytes: [137, 80, 78, 71, 13, 10, 26, 10], + contentType: "image/png", + }); await base44.asServiceRole.connectors.callApi("googlemaps", { host: "places", path: "/v1/places:searchText", @@ -104,14 +97,9 @@ describe("Connectors module – metered connector proxy", () => { }); test("maps a binary response to dataBase64 + contentType", async () => { - platform.given.app(appId).connectors.proxyOutcome("googlemaps", { - success: true, - phase: "responded", - status: 200, - data: null, - dataBase64: "iVBORw0KGgo=", + platform.given.app(appId).connectors.mapsApi("googlemaps", { + bytes: [137, 80, 78, 71, 13, 10, 26, 10], contentType: "image/png", - creditsCharged: 1, }); const result = await base44.asServiceRole.connectors.callApi("googlemaps", { path: "/maps/api/staticmap", @@ -151,18 +139,27 @@ describe("Connectors module – metered connector proxy", () => { test("maps the proxy envelope to camelCase", async () => { const result = await base44.asServiceRole.connectors.callApi("x", { + method: "POST", path: "/2/tweets", + body: { text: "hello" }, }); expect(result).toEqual({ success: true, phase: "responded", status: 201, - data: { data: { id: "1" } }, + data: { data: { id: "1", text: "hello" } }, dataBase64: null, contentType: null, headers: { "x-rate-limit-remaining": "42" }, creditsCharged: 3, }); + await expect( + base44.asServiceRole.connectors.callApi("x", { + path: "/2/tweets/search/recent", + }), + ).resolves.toMatchObject({ + data: { data: [{ id: "1", text: "hello" }] }, + }); }); test("isolates proxy outcomes by application", async () => { @@ -171,11 +168,9 @@ describe("Connectors module – metered connector proxy", () => { platform.given.app(otherAppId).auth.servicePrincipal(otherServiceToken, { id: "other-service-principal", }); - platform.given.app(otherAppId).connectors.proxyOutcome("x", { - ...responded, - status: 202, - data: { app: "other" }, - }); + platform.given + .app(otherAppId) + .connectors.socialApi("x", { id: "account-b" }); const otherClient = createClient({ serverUrl: "https://base44.app", appId: otherAppId, @@ -184,21 +179,21 @@ describe("Connectors module – metered connector proxy", () => { await expect( base44.asServiceRole.connectors.callApi("x", { path: "/scope" }), - ).resolves.toMatchObject({ status: 201, data: { data: { id: "1" } } }); + ).resolves.toMatchObject({ + status: 200, + data: { data: { id: "account-a" } }, + }); await expect( otherClient.asServiceRole.connectors.callApi("x", { path: "/scope" }), - ).resolves.toMatchObject({ status: 202, data: { app: "other" } }); + ).resolves.toMatchObject({ + status: 200, + data: { data: { id: "account-b" } }, + }); otherClient.cleanup(); }); test("returns an upstream error instead of throwing", async () => { - platform.given.app(appId).connectors.proxyOutcome("x", { - success: false, - phase: "responded", - status: 400, - data: { title: "Invalid Request" }, - creditsCharged: 3, - }); + platform.given.app(appId).faults.connectors.upstreamRejected("x"); const result = await base44.asServiceRole.connectors.callApi("x", { method: "POST", path: "/2/tweets", @@ -213,13 +208,37 @@ describe("Connectors module – metered connector proxy", () => { }); }); + test("derives an upstream validation result from the submitted body", async () => { + await expect( + base44.asServiceRole.connectors.callApi("x", { + method: "POST", + path: "/2/tweets", + body: {}, + }), + ).resolves.toMatchObject({ + success: false, + phase: "responded", + status: 400, + data: { title: "Tweet text is required" }, + creditsCharged: 3, + }); + }); + test("rejects when Base44 itself refuses the call", async () => { platform.given.app(appId).faults.connectors.creditsExhausted("x"); await expect( - base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }), + base44.asServiceRole.connectors.callApi("x", { + method: "POST", + path: "/2/tweets", + body: { text: "after retry" }, + }), ).rejects.toMatchObject({ status: 402 }); await expect( - base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets" }), + base44.asServiceRole.connectors.callApi("x", { + method: "POST", + path: "/2/tweets", + body: { text: "recovered" }, + }), ).resolves.toMatchObject({ success: true, status: 201, @@ -255,17 +274,19 @@ describe("Connectors module – metered connector proxy", () => { test.each(["not_sent", "timed_out", "sent_unconfirmed"] as const)( "maps proxy phase %s when no upstream response is available", async (phase) => { - platform.given.app(appId).connectors.proxyOutcome("x", { - success: false, + const faults = platform.given.app(appId).faults.connectors; + if (phase === "not_sent") faults.notSent("x"); + if (phase === "timed_out") faults.timedOut("x"); + if (phase === "sent_unconfirmed") faults.sentUnconfirmed("x"); + const result = await base44.asServiceRole.connectors.callApi("x", { + path: "/2/tweets", + }); + expect(result).toMatchObject({ phase, status: null, - data: { error: "request outcome unknown" }, + success: false, creditsCharged: phase === "not_sent" ? 0 : 3, }); - const result = await base44.asServiceRole.connectors.callApi("x", { - path: "/2/tweets", - }); - expect(result).toMatchObject({ phase, status: null, success: false }); }, ); diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts index 09562036..3aa901a5 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -15,9 +15,10 @@ describe("Custom Integrations Module", () => { const operationId = "get:/repos/{owner}/{repo}/issues"; platform.given .app(appId) - .customIntegrations.operation("github", operationId, { - issues: [{ id: 1, title: "Test Issue" }], - }); + .customIntegrations.githubRepository("github", "testuser", "testrepo", [ + { id: 1, title: "Test Issue", state: "open" }, + { id: 2, title: "Closed Issue", state: "closed" }, + ]); const result = await base44.integrations.custom.call( "github", operationId, @@ -29,6 +30,7 @@ describe("Custom Integrations Module", () => { ); expect(result).toMatchObject({ success: true, status_code: 200 }); expect(result.data.issues).toHaveLength(1); + expect(result.data.issues[0]).toMatchObject({ id: 1, state: "open" }); expect(platform.requests.last("customIntegrations.call").body).toEqual({ payload: { title: "Test Issue" }, path_params: { owner: "testuser", repo: "testrepo" }, @@ -37,12 +39,10 @@ describe("Custom Integrations Module", () => { }); test("works with empty params", async () => { - platform.given - .app(appId) - .customIntegrations.operation("github", "getAuthenticatedUser", { - login: "testuser", - id: 123, - }); + platform.given.app(appId).customIntegrations.githubUser("github", { + login: "testuser", + id: 123, + }); const result = await base44.integrations.custom.call( "github", "getAuthenticatedUser", @@ -64,7 +64,7 @@ describe("Custom Integrations Module", () => { test("maps missing operation to a 404 Base44Error", async () => { platform.given .app(appId) - .customIntegrations.operation("github", "existingOperation", {}); + .customIntegrations.operationAvailable("github", "existingOperation"); await expect( base44.integrations.custom.call("github", "nonExistentOperation"), ).rejects.toMatchObject({ @@ -79,7 +79,12 @@ describe("Custom Integrations Module", () => { const operationId = "get:/repos/{owner}/{repo}/issues"; platform.given .app(appId) - .customIntegrations.operation("github", operationId, { issues: [] }); + .customIntegrations.githubRepository( + "github", + "testuser", + "testrepo", + [], + ); platform.given .app(appId) .faults.customIntegrations.upstreamUnavailable("github", operationId); @@ -123,25 +128,26 @@ describe("Custom Integrations Module", () => { description: "A".repeat(100), metadata: { key: `value_${id}` }, })); - platform.given - .app(appId) - .customIntegrations.operation("myapi", "bulkCreate", { created: 1000 }); + platform.given.app(appId).customIntegrations.inventory("myapi"); const result = await base44.integrations.custom.call( "myapi", "bulkCreate", { payload: { items } }, ); expect(result.data.created).toBe(1000); - expect(platform.requests.last("customIntegrations.call").body).toEqual({ + await expect( + base44.integrations.custom.call("myapi", "listItems"), + ).resolves.toMatchObject({ + data: { items }, + }); + expect(platform.requests.all("customIntegrations.call")[0].body).toEqual({ payload: { items }, }); }); test("includes custom headers in the backend request body", async () => { const headers = { "X-Custom-Header": "custom-value" }; - platform.given - .app(appId) - .customIntegrations.operation("myapi", "getData", { result: "ok" }); + platform.given.app(appId).customIntegrations.requestInspector("myapi"); await base44.integrations.custom.call("myapi", "getData", { headers }); expect(platform.requests.last("customIntegrations.call").body).toEqual({ headers, @@ -157,9 +163,7 @@ describe("Custom Integrations Module", () => { }; platform.given .app(appId) - .customIntegrations.operation("myapi", "secureEndpoint", { - authenticated: true, - }); + .customIntegrations.apiKeyProtected("myapi", "secret-key-123"); const result = await base44.integrations.custom.call( "myapi", "secureEndpoint", @@ -173,11 +177,9 @@ describe("Custom Integrations Module", () => { test("only includes defined params in body", async () => { const operationId = "get:/users/{username}"; - platform.given - .app(appId) - .customIntegrations.operation("github", operationId, { - login: "octocat", - }); + platform.given.app(appId).customIntegrations.githubUser("github", { + login: "octocat", + }); await base44.integrations.custom.call("github", operationId, { pathParams: { username: "octocat" }, }); @@ -191,7 +193,7 @@ describe("Custom Integrations Module", () => { // Legacy SDK compatibility; current Apper has removed this route. platform.given .app(appId) - .integrations.packageSucceeds("SomePackage", "SomeEndpoint"); + .integrations.legacyEndpoint("SomePackage", "SomeEndpoint"); await expect( base44.integrations.Core.SendEmail({ to: "test@example.com", @@ -210,12 +212,10 @@ describe("Custom Integrations Module", () => { const sharedAppId = "shared-custom-app"; platform.given.app(isolatedAppId).workspace("workspace-b"); platform.given.app(sharedAppId).workspace("workspace-a"); - platform.given - .app(appId) - .customIntegrations.operation("github", "whoami", { workspace: "a" }); + platform.given.app(appId).customIntegrations.workspaceIdentity("github"); platform.given .app(isolatedAppId) - .customIntegrations.operation("github", "whoami", { workspace: "b" }); + .customIntegrations.workspaceIdentity("github"); const isolated = createClient({ serverUrl: "https://base44.app", appId: isolatedAppId, @@ -228,17 +228,17 @@ describe("Custom Integrations Module", () => { await expect( base44.integrations.custom.call("github", "whoami"), ).resolves.toMatchObject({ - data: { workspace: "a" }, + data: { workspaceId: "workspace-a" }, }); await expect( isolated.integrations.custom.call("github", "whoami"), ).resolves.toMatchObject({ - data: { workspace: "b" }, + data: { workspaceId: "workspace-b" }, }); await expect( shared.integrations.custom.call("github", "whoami"), ).resolves.toMatchObject({ - data: { workspace: "a" }, + data: { workspaceId: "workspace-a" }, }); isolated.cleanup(); shared.cleanup(); diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts index 435d417b..affe6298 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -108,6 +108,34 @@ describe("Entities Module", () => { }); }); + test("list() groups missing and null values consistently around sorted pages", async () => { + platform.given.app(appId).entities.records("Todo", [ + { id: "missing", title: "Missing" }, + { id: "null", title: "Null", description: null }, + { id: "zulu", title: "Zulu", description: "Zulu" }, + { id: "alpha", title: "Alpha", description: "Alpha" }, + ]); + + await expect(base44.entities.Todo.list("description")).resolves.toEqual([ + { id: "missing", title: "Missing" }, + { id: "null", title: "Null", description: null }, + { id: "alpha", title: "Alpha", description: "Alpha" }, + { id: "zulu", title: "Zulu", description: "Zulu" }, + ]); + await expect(base44.entities.Todo.list("-description")).resolves.toEqual([ + { id: "zulu", title: "Zulu", description: "Zulu" }, + { id: "alpha", title: "Alpha", description: "Alpha" }, + { id: "missing", title: "Missing" }, + { id: "null", title: "Null", description: null }, + ]); + await expect( + base44.entities.Todo.list("description", 2, 1), + ).resolves.toEqual([ + { id: "null", title: "Null", description: null }, + { id: "alpha", title: "Alpha", description: "Alpha" }, + ]); + }); + test("filter() sends the query and returns matching domain state", async () => { platform.given.app(appId).entities.records("Todo", [ { id: "1", title: "Task 1", completed: false }, diff --git a/tests/unit/integrations.test.js b/tests/unit/integrations.test.js index 17c510eb..0625a684 100644 --- a/tests/unit/integrations.test.js +++ b/tests/unit/integrations.test.js @@ -33,13 +33,11 @@ describe("Integrations Module", () => { // no longer exposes installable-package integrations. platform.given .app(appId) - .integrations.packageSucceeds("CustomPackage", "CustomEndpoint", { - result: "custom result", - }); + .integrations.legacyEndpoint("CustomPackage", "CustomEndpoint"); const params = { param1: "value1", param2: "value2" }; const result = await base44.integrations.CustomPackage.CustomEndpoint(params); - expect(result).toEqual({ success: true, result: "custom result" }); + expect(result).toEqual({ success: true, received: params }); expect(platform.requests.last("integrations.invoke")).toMatchObject({ method: "POST", body: params, diff --git a/tests/unit/integrations.test.ts b/tests/unit/integrations.test.ts index 096930ab..57721643 100644 --- a/tests/unit/integrations.test.ts +++ b/tests/unit/integrations.test.ts @@ -11,30 +11,22 @@ describe("Core Integrations - InvokeLLM", () => { afterEach(() => base44.cleanup()); test("passes model parameter to the API", async () => { - platform.given - .app(appId) - .integrations.llmResponds("Quantum computing uses qubits..."); const params = { prompt: "Explain quantum computing", model: "gpt_5" }; await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe( - "Quantum computing uses qubits...", + "Mock LLM text response", ); expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); test("works without model parameter", async () => { - platform.given - .app(appId) - .integrations.llmResponds("Quantum computing uses qubits..."); const params = { prompt: "Explain quantum computing" }; await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe( - "Quantum computing uses qubits...", + "Mock LLM text response", ); expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); test("passes model alongside other optional parameters", async () => { - const response = { sentiment: "positive" }; - platform.given.app(appId).integrations.llmResponds(response); const params = { prompt: "Analyze this text", model: "claude_sonnet_4_6" as const, @@ -43,9 +35,10 @@ describe("Core Integrations - InvokeLLM", () => { properties: { sentiment: { type: "string" } }, }, }; - await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toEqual( - response, - ); + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toEqual({ + mock: true, + kind: "structured", + }); expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); }); diff --git a/tests/unit/mock-platform-architecture.test.ts b/tests/unit/mock-platform-architecture.test.ts index e872a770..896d1777 100644 --- a/tests/unit/mock-platform-architecture.test.ts +++ b/tests/unit/mock-platform-architecture.test.ts @@ -14,6 +14,14 @@ describe("mock platform architecture", () => { ["from ", "'msw'"].join(""), ["mocks/", "server"].join(""), ["given.functions", ".result("].join(""), + ["proxy", "Outcome("].join(""), + ["package", "Succeeds("].join(""), + ["llm", "Responds("].join(""), + ["customIntegrations", ".operation("].join(""), + ["app", ".publicSettings("].join(""), + ["auth", ".registration("].join(""), + ["passwordReset", "Request("].join(""), + ["actors", ".available("].join(""), ]; const violations = readdirSync(unitDirectory) .filter((name) => /\.test\.[jt]s$/.test(name)) @@ -21,11 +29,146 @@ describe("mock platform architecture", () => { .filter((path) => path !== thisFile) .flatMap((path) => { const source = readFileSync(path, "utf8"); - return forbidden + const forbiddenPatterns = forbidden .filter((pattern) => source.includes(pattern)) .map((pattern) => `${path.split("/").at(-1)} contains ${pattern}`); + const directFixtureImport = source.match( + /from ["']\.\.\/mocks\/platform\/(?!index(?:\.ts)?["'])[^"']+["']/, + ); + return directFixtureImport + ? [ + ...forbiddenPatterns, + `${path.split("/").at(-1)} imports a fixture module directly`, + ] + : forbiddenPatterns; }); expect(violations).toEqual([]); }); + + test("response-template fixture APIs cannot reappear under new names", async () => { + const { platform } = await import("../mocks/platform/index.ts"); + const app = platform.given.app("architecture-guard-app"); + + expect(Object.keys(platform).sort()).toEqual([ + "given", + "requests", + "reset", + ]); + expect(Object.keys(platform.given).sort()).toEqual([ + "app", + "faults", + "functions", + "generic", + ]); + expect(Object.keys(platform.given.functions).sort()).toEqual([ + "legacyEndpoint", + ]); + expect(Object.keys(platform.given.generic).sort()).toEqual(["route"]); + expect(Object.keys(platform.given.faults).sort()).toEqual([]); + expect(Object.keys(app).sort()).toEqual([ + "actors", + "agents", + "auth", + "connectors", + "customIntegrations", + "deployment", + "entities", + "faults", + "functions", + "integrations", + "sso", + "workspace", + ]); + expect(Object.keys(app.agents).sort()).toEqual([ + "conversationsForUser", + "conversationsForVisitor", + ]); + expect(Object.keys(app.entities).sort()).toEqual(["records"]); + expect(Object.keys(app.functions).sort()).toEqual([ + "arrayProcessor", + "authenticatedProbe", + "documentProcessor", + "fileStore", + "formSubmissions", + "inputReceipt", + "notificationDelivery", + "serviceExecution", + "serviceHealth", + "uploadAcceptance", + "userProcessor", + ]); + + expect(Object.keys(app.integrations).sort()).toEqual([ + "emailDelivered", + "fileUploaded", + "legacyEndpoint", + ]); + expect(Object.keys(app.customIntegrations).sort()).toEqual([ + "apiKeyProtected", + "githubRepository", + "githubUser", + "inventory", + "operationAvailable", + "requestInspector", + "workspaceIdentity", + ]); + expect(Object.keys(app.connectors).sort()).toEqual([ + "appUserAuthorization", + "appUserConnection", + "connection", + "echoApi", + "mapsApi", + "socialApi", + "workspaceConnection", + ]); + expect(Object.keys(app.auth).sort()).toEqual([ + "account", + "meLatency", + "principal", + "registrationChallenge", + "resetToken", + "servicePrincipal", + ]); + expect(Object.keys(app.deployment).sort()).toEqual([ + "deploymentAccess", + "legacyAccessDenied", + ]); + expect(Object.keys(app.actors).sort()).toEqual(["deployed", "fault"]); + expect(Object.keys(app.sso).sort()).toEqual(["tokens"]); + expect(Object.keys(app.faults).sort()).toEqual([ + "auth", + "connectors", + "customIntegrations", + "functions", + "integrations", + ]); + expect(Object.keys(app.faults.auth).sort()).toEqual([ + "invalidCredentials", + "networkUnavailableLogin", + "networkUnavailableMe", + "registrationRejected", + "rejectedUpdate", + "resetTokenExpired", + ]); + expect(Object.keys(app.faults.functions).sort()).toEqual([ + "internalError", + "networkUnavailable", + "notFound", + ]); + expect(Object.keys(app.faults.integrations).sort()).toEqual([ + "invalidParameters", + ]); + expect(Object.keys(app.faults.customIntegrations).sort()).toEqual([ + "upstreamUnavailable", + ]); + expect(Object.keys(app.faults.connectors).sort()).toEqual([ + "creditsExhausted", + "meteredTokenRequiresProxy", + "notSent", + "sentUnconfirmed", + "timedOut", + "upstreamRejected", + ]); + }); }); diff --git a/tests/unit/sso.test.ts b/tests/unit/sso.test.ts index ddbf7df3..32e9eded 100644 --- a/tests/unit/sso.test.ts +++ b/tests/unit/sso.test.ts @@ -11,7 +11,7 @@ describe("SSO module", () => { let base44: ReturnType; beforeEach(() => { - platform.given.sso.tokens(userId, { + platform.given.app(appId).sso.tokens(userId, { idToken: "header.payload.signature", accessToken: "access-token-123", }); @@ -21,12 +21,18 @@ describe("SSO module", () => { afterEach(() => base44.cleanup()); test("getIdToken issues the app-scoped GET request and returns the raw token", async () => { - await expect(base44.asServiceRole.sso.getIdToken(userId)).resolves.toBe("header.payload.signature"); - expect(platform.requests.last("sso.getIdToken").url).toContain(`/api/apps/${appId}/auth/sso/idtoken/${userId}`); + await expect(base44.asServiceRole.sso.getIdToken(userId)).resolves.toBe( + "header.payload.signature", + ); + expect(platform.requests.last("sso.getIdToken").url).toContain( + `/api/apps/${appId}/auth/sso/idtoken/${userId}`, + ); }); test("getAccessToken issues the existing GET request and returns the raw token", async () => { - await expect(base44.asServiceRole.sso.getAccessToken(userId)).resolves.toBe("access-token-123"); + await expect(base44.asServiceRole.sso.getAccessToken(userId)).resolves.toBe( + "access-token-123", + ); }); test("getIdToken uses the service-role client with on-behalf-of authentication", async () => { @@ -37,8 +43,12 @@ describe("SSO module", () => { }); test("getIdToken surfaces a 404 when no ID token is stored", async () => { - platform.given.sso.tokens("another-user", { accessToken: "only-access" }); - await expect(base44.asServiceRole.sso.getIdToken("another-user")).rejects.toMatchObject({ + platform.given + .app(appId) + .sso.tokens("another-user", { accessToken: "only-access" }); + await expect( + base44.asServiceRole.sso.getIdToken("another-user"), + ).rejects.toMatchObject({ name: "Base44Error", status: 404, code: "NOT_FOUND",