diff --git a/package-lock.json b/package-lock.json index 41f0fd47..c9c8621e 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.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "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.0", + "@inquirer/type": "^4.0.7" + }, + "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.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.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", + "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.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", + "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "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": { + "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.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.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "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" + }, "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.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": { + "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/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) { diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..d34e0d3f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,56 @@ +# SDK HTTP tests + +`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. + +## Write a platform-backed test + +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 { platform } from "./mocks/platform"; + +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", + 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, +}); +``` + +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. + +## Extend the mock platform + +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. + +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`: 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 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. 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/platform/actors.ts b/tests/mocks/platform/actors.ts new file mode 100644 index 00000000..2d7ca228 --- /dev/null +++ b/tests/mocks/platform/actors.ts @@ -0,0 +1,74 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest } from "./state"; + +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 faults = new Map(); +const scoped = (appId: string, name: string) => `${appId}\u0000${name}`; + +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(); + faults.clear(); +} + +export const actorHandlers = [ + 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/agents.ts b/tests/mocks/platform/agents.ts new file mode 100644 index 00000000..dfecc44f --- /dev/null +++ b/tests/mocks/platform/agents.ts @@ -0,0 +1,126 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest, state, type PlatformConversation } from "./state"; +import { principalFor } from "./auth"; + +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}`, + ), + }; + 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 resolved = authorizedConversation( + String(params.appId), + request, + String(params.conversationId), + ); + if (resolved.response) return resolved.response; + const message = { id: `msg-${state.nextMessageId++}`, ...input }; + resolved.stored!.record.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..ff4b2aa4 --- /dev/null +++ b/tests/mocks/platform/app.ts @@ -0,0 +1,46 @@ +import { http, HttpResponse } from "msw"; +import { recordRequest } from "./state"; + +const accessPolicies = new Map(); +const accessFaults = new Map(); + +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() { + 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 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 new file mode 100644 index 00000000..19f6eed3 --- /dev/null +++ b/tests/mocks/platform/auth.ts @@ -0,0 +1,289 @@ +import { delay, http, HttpResponse } from "msw"; +import { recordRequest, state, type PlatformFault } from "./state"; + +export type User = Record & { id: string; email?: string }; + +export interface LoginAccount { + email: string; + password: string; + accessToken: string; + user: User; + countryCode?: string; +} + +interface Principal { + appId: string; + user: User; + kind: "user" | "service"; +} +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, headerName = "authorization") { + const authorization = request.headers.get(headerName); + return authorization?.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : undefined; +} + +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; +} + +function unauthorized() { + return HttpResponse.json({ detail: "Unauthorized" }, { status: 401 }); +} + +export function resetAuthState() { + accounts.clear(); + principals.clear(); + resetTokens.clear(); + meLatencies.clear(); + rejectedUpdates = new Map(); + invalidLogins = new Set(); + unavailableLogins = new Set(); + unavailableMe = new Set(); +} + +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, + kind: "user", + }); + }, + principal(token: string, user: User) { + const stored = structuredClone(user); + 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); + }, + resetToken( + resetToken: string, + email: string, + options: { expired?: boolean } = {}, + ) { + resetTokenStore(appId).set(resetToken, { + email, + expired: options.expired ?? false, + consumed: false, + }); + }, + }; +} + +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); + if (index < 0) return false; + state.faults.splice(index, 1); + return true; +} + +export const authHandlers = [ + http.get( + "*/api/apps/:appId/entities/User/me", + async ({ params, request }) => { + await recordRequest("auth.me", request); + 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 ({ params, request }) => { + await recordRequest("auth.updateMe", request); + 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; + Object.assign(resolved.principal.user, updates); + return HttpResponse.json(structuredClone(resolved.principal.user)); + }, + ), + 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 }, + ); + 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, + kind: "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.userId, + message: "Verification required", + otp_expires_in_minutes: registration.otpTtlMinutes, + country_code: registration.countryCode, + }); + }), + http.post( + "*/api/apps/:appId/auth/reset-password-request", + async ({ request }) => { + await recordRequest("auth.resetPasswordRequest", request); + await request.clone().json(); + return HttpResponse.json({ message: "Request accepted" }); + }, + ), + http.post( + "*/api/apps/:appId/auth/reset-password", + async ({ params, request }) => { + await recordRequest("auth.resetPassword", request); + 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: resetToken?.expired + ? "Reset token expired" + : "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/connectors.ts b/tests/mocks/platform/connectors.ts new file mode 100644 index 00000000..670a47cb --- /dev/null +++ b/tests/mocks/platform/connectors.ts @@ -0,0 +1,477 @@ +import { http, HttpResponse } from "msw"; +import { + recordRequest, + state, + type ConnectorProxyService, + type ConnectorToken, + type PlatformFault, +} from "./state"; +import { principalFor } from "./auth"; + +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 }), + }; +} + +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); + }, + 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", + }); + }, + }; +} + +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, + }); + }, + 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, + }; +} + +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({ + 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); + 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)), + ); + }, + ), + 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 }) => { + const recorded = 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, + ); + 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 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 (!service) + return HttpResponse.json( + { detail: "Connector proxy not configured", code: "NOT_FOUND" }, + { status: 404 }, + ); + return HttpResponse.json( + proxyEnvelope(service, recorded.body as Record), + ); + }, + ), + 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 new file mode 100644 index 00000000..af147efe --- /dev/null +++ b/tests/mocks/platform/entities.ts @@ -0,0 +1,199 @@ +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(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 = []; + 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 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 (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"); + 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 = compareValues(left[field], 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.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 new file mode 100644 index 00000000..ca478c01 --- /dev/null +++ b/tests/mocks/platform/functions.ts @@ -0,0 +1,85 @@ +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 }) => { + 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, + ) + ) { + return HttpResponse.error(); + } + if ( + takeFault( + (fault) => + fault.kind === "function-internal-error" && + fault.appId === appId && + fault.functionName === functionName, + ) + ) { + return HttpResponse.json( + { error: "Internal server error", code: "INTERNAL_ERROR" }, + { status: 500 }, + ); + } + if ( + takeFault( + (fault) => + fault.kind === "function-not-found" && + fault.appId === appId && + fault.functionName === functionName, + ) || + !state.functionBehaviors.get(appId)?.has(functionName) + ) { + return HttpResponse.json( + { error: "Function not found", code: "FUNCTION_NOT_FOUND" }, + { status: 404 }, + ); + } + 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 as any); + }, + ), + // 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.legacyFunctions.has(path)) { + return HttpResponse.json( + { detail: "Function not found" }, + { status: 404 }, + ); + } + return new HttpResponse("ok", { 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..e50cd566 --- /dev/null +++ b/tests/mocks/platform/index.ts @@ -0,0 +1,325 @@ +import { actorFixturesFor, actorHandlers, resetActorState } from "./actors"; +import { agentHandlers } from "./agents"; +import { analyticsHandlers } from "./analytics"; +import { appFixturesFor, appHandlers, resetAppState } from "./app"; +import { + authFaultFixturesFor, + authFixturesFor, + authHandlers, + resetAuthState, +} from "./auth"; +import { + connectorFaultFixturesFor, + connectorFixturesFor, + connectorHandlers, +} from "./connectors"; +import { entityHandlers } from "./entities"; +import { functionHandlers } from "./functions"; +import { genericFixtures, genericHandlers, resetGenericState } from "./generic"; +import { + customIntegrationFaultFixturesFor, + customIntegrationFixturesFor, + integrationFaultFixturesFor, + integrationFixturesFor, + integrationHandlers, +} from "./integrations"; +import { resetSsoState, ssoFixturesFor, ssoHandlers } from "./sso"; +import { + resetPlatformState, + state, + type PlatformConversation, + type PlatformRegistration, + type PlatformRecord, + type RecordedRequest, + type FunctionBehavior, + type MultipartBody, +} 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(); +} + +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); + 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, + 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); + 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.nextEntityIds.set( + appId, + Math.max( + state.nextEntityIds.get(appId) ?? 1, + ...numericIds.map((id) => id + 1), + ), + ); + }, + }, + auth: { + ...auth, + registrationChallenge(email: string, registration: PlatformRegistration) { + state.registrations.set(`${appId}\u0000${email}`, clone(registration)); + }, + }, + functions: { + 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, + })); + }, + 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, + customIntegrations, + connectors, + faults: { + auth: { + ...authFaults, + registrationRejected(email: string) { + state.faults.push({ + kind: "auth-registration-rejected", + appId, + email, + }); + }, + }, + functions: { + internalError(functionName: string) { + state.faults.push({ + kind: "function-internal-error", + appId, + functionName, + }); + }, + notFound(functionName: string) { + state.faults.push({ + kind: "function-not-found", + appId, + functionName, + }); + }, + networkUnavailable(functionName: string) { + state.faults.push({ + kind: "function-network-unavailable", + appId, + functionName, + }); + }, + }, + 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), + ); +} + +export const platform = { + reset, + given: { + app: forApp, + functions: { + legacyEndpoint(functionPath: string) { + state.legacyFunctions.add(functionPath.replace(/^\//, "")); + }, + }, + generic: genericFixtures, + faults: {}, + }, + 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..f92f74ee --- /dev/null +++ b/tests/mocks/platform/integrations.ts @@ -0,0 +1,336 @@ +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; +} + +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 { + legacyEndpoint(packageName: string, endpointName: string) { + integrationStore(appId).set(endpointKey(packageName, endpointName), { + kind: "legacy-endpoint", + }); + }, + emailDelivered(messageId = "123456") { + integrationStore(appId).set(endpointKey("Core", "SendEmail"), { + kind: "email-delivery", + nextId: messageId, + }); + }, + fileUploaded(fileId = "file123") { + integrationStore(appId).set(endpointKey("Core", "UploadFile"), { + kind: "file-upload", + nextId: fileId, + }); + }, + }; +} + +export function integrationFaultFixturesFor(appId: string) { + return { + invalidParameters(packageName: string, endpointName: string) { + state.faults.push({ + kind: "integration-invalid-parameters", + appId, + packageName, + endpointName, + }); + }, + }; +} + +function invokeIntegration( + appId: string, + packageName: string, + endpointName: string, + body: unknown, +) { + const fault = takeFault( + (item) => + item.kind === "integration-invalid-parameters" && + item.appId === appId && + item.packageName === packageName && + item.endpointName === endpointName, + ); + if (fault) + return HttpResponse.json( + { 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), + ); + if (!endpoint) + return HttpResponse.json( + { + detail: `Integration endpoint '${packageName}.${endpointName}' not found`, + code: "NOT_FOUND", + }, + { status: 404 }, + ); + 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) { + const match = new URL(request.url).pathname.match( + /^\/api\/apps\/([^/]+)\/integrations\/custom\/([^/]+)\/(.+)$/, + ); + if (!match) return undefined; + 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 function customIntegrationFixturesFor(appId: string) { + return { + githubRepository( + slug: string, + 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, { + 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) { + 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 }) => { + const recorded = await recordRequest("integrations.invoke", request); + return invokeIntegration( + String(params.appId), + "Core", + String(params.endpointName), + recorded.body, + ); + }, + ), + 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 }) => { + 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 }) => { + const recorded = 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: true, + 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 new file mode 100644 index 00000000..97b756b9 --- /dev/null +++ b/tests/mocks/platform/sso.ts @@ -0,0 +1,68 @@ +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. + +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(); + 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.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(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..73db0ef6 --- /dev/null +++ b/tests/mocks/platform/state.ts @@ -0,0 +1,241 @@ +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 StoredConversation { + appId: string; + owner: { kind: "user" | "visitor"; id: string }; + record: PlatformConversation; +} + +export interface PlatformRegistration { + userId: string; + otpTtlMinutes: number; + countryCode: string | null; +} + +export interface IntegrationEndpoint { + kind: "email-delivery" | "file-upload" | "legacy-endpoint"; + nextId?: string; +} + +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; + integrationType: string; + connectionConfig?: Record | null; +} + +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; + functionName: string; + body: unknown; + query: Record; + headers: Record; +} + +export type FunctionBehavior = ( + invocation: FunctionInvocation, +) => unknown | Promise; + +export type PlatformFault = + | { + kind: "integration-invalid-parameters"; + appId: string; + packageName: string; + endpointName: 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: + | "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 } + | { + kind: "function-network-unavailable"; + appId: string; + functionName: string; + }; + +interface PlatformState { + entities: Map>; + conversations: Map; + integrationEndpoints: Map>; + appWorkspaces: Map; + customIntegrations: Map< + string, + Map> + >; + connectorTokens: Map>; + workspaceConnectorTokens: Map>; + appUserConnectorTokens: Map>>; + appUserConnectorRedirects: Map>>; + connectorProxyServices: Map>; + registrations: Map; + functionBehaviors: Map>; + legacyFunctions: Set; + faults: PlatformFault[]; + nextEntityIds: Map; + nextConversationId: number; + nextMessageId: number; + requests: RecordedRequest[]; +} + +export const state: PlatformState = { + entities: new Map(), + conversations: new Map(), + integrationEndpoints: new Map(), + appWorkspaces: new Map(), + customIntegrations: new Map(), + connectorTokens: new Map(), + workspaceConnectorTokens: new Map(), + appUserConnectorTokens: new Map(), + appUserConnectorRedirects: new Map(), + connectorProxyServices: new Map(), + registrations: new Map(), + functionBehaviors: new Map(), + legacyFunctions: new Set(), + faults: [], + nextEntityIds: new Map(), + nextConversationId: 1, + nextMessageId: 1, + requests: [], +}; + +export function resetPlatformState() { + state.entities.clear(); + 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.connectorProxyServices.clear(); + state.registrations.clear(); + state.functionBehaviors.clear(); + state.legacyFunctions.clear(); + state.faults = []; + state.nextEntityIds.clear(); + state.nextConversationId = 1; + state.nextMessageId = 1; + state.requests = []; +} + +export async function recordRequest( + route: string, + request: Request, +): Promise { + 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(); + } + } + 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/mocks/server.ts b/tests/mocks/server.ts new file mode 100644 index 00000000..63617e93 --- /dev/null +++ b/tests/mocks/server.ts @@ -0,0 +1,13 @@ +import { setupServer } from "msw/node"; +import { platformHandlers } from "./platform"; + +/** + * Transport boundary for the reusable mock Base44 platform. + * + * 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. + */ +export const server = setupServer(...platformHandlers); 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..d2faeae2 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,28 +1,39 @@ -// Load environment variables from .env file -import dotenv from 'dotenv'; -import './utils/circular-json-handler.js'; -import { beforeAll, afterAll, test } from 'vitest'; +import { beforeAll, beforeEach, afterAll, afterEach, expect } from "vitest"; +import { server } from "./mocks/server.ts"; +import { platform } from "./mocks/platform/index.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(); + // 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}`); + }, + }); }); - -afterAll(() => { - console.log('Completed Base44 SDK tests'); - // Add any global teardown here -}); \ No newline at end of file +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([]); + 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"); +}); +afterAll(() => server.close()); diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index e79516e1..d67a44d9 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import nock from "nock"; +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 @@ -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,21 @@ 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", - }, - }) - .post(`/api/apps/${appId}/actors/PongGame/connection-token`, { - room: "r1", - connection_id: "c1", - }) - .reply(200, { - 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.app(appId).actors.deployed("PongGame", { + websocketHost: "wss://actors.example", + scriptId: "scr_1", + issuedToken: "jwt.min.ted", + tokenExpiresAt: "2026-01-01T00:00:00Z", + mode: "preview", + }); const base44 = createClient({ serverUrl, @@ -579,17 +617,27 @@ 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); + 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 () => { - nock(serverUrl) - .post(`/api/apps/${appId}/actors/PongGame/connection-token`) - .reply(409, { message: "Actor must be migrated before connecting directly" }); + platform.given.app(appId).actors.fault("PongGame", "legacy-conflict"); 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 +650,15 @@ 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, { - error_type: "HTTPException", - message: "Method Not Allowed", - detail: "Method Not Allowed", - }); + platform.given.app(appId).actors.fault("PongGame", "endpoint-unsupported"); 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 +668,15 @@ 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" }); + platform.given.app(appId).actors.fault("PongGame", "mint-failed"); 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,29 +697,29 @@ describe("Actors Module — client wiring", () => { vi.stubGlobal("document", undefined); vi.stubGlobal("localStorage", undefined); - 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"]); - return [ - 200, - { - websocket_url: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", - token: "jwt.min.ted", - }, - ]; - }); + 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", + }); 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(); }); @@ -681,25 +731,27 @@ 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, { - websocket_url: "wss://actors.example/v1/actors/scr_1/rooms/r1?_pk=c1", - token: "jwt.min.ted", - }); + 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", + }); 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", + ); + const request = platform.requests.last("actors.mintConnectionToken"); + expect(request.headers.authorization).toBe("Bearer tok"); + expect(request.headers["x-base44-anonymous-id"]).toBeUndefined(); 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..54527b84 100644 --- a/tests/unit/agents.test.ts +++ b/tests/unit/agents.test.ts @@ -1,101 +1,239 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform"; describe("Agents Module", () => { let base44: ReturnType; - let scope: nock.Scope; - const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; + const appId = "test-app-id"; + const userToken = "agent-user-token"; + const userId = "agent-user"; beforeEach(() => { - base44 = createClient({ serverUrl, appId }); - scope = nock(serverUrl); - nock.disableNetConnect(); + platform.reset(); + platform.given.app(appId).auth.principal(userToken, { id: userId }); + base44 = createClient({ serverUrl, appId, token: userToken }); }); - afterEach(() => { - nock.cleanAll(); - nock.enableNetConnect(); + 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 + .app(appId) + .agents.conversationsForUser(userId, 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: [] }, - ]; - scope.get(`/api/apps/${appId}/agents/conversations`).reply(200, mockConversations); + test("getConversation() returns one arranged conversation", async () => { + const conversation = { id: "conv-1", agent_name: "support", messages: [] }; + platform.given + .app(appId) + .agents.conversationsForUser(userId, [conversation]); - const result = await base44.agents.getConversations(); - expect(result).toEqual(mockConversations); - }); + 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: [] }; - scope.get(`/api/apps/${appId}/agents/conversations/conv-1`).reply(200, mockConversation); + test("createConversation() persists so list and get observe it", async () => { + const created = await base44.agents.createConversation({ + agent_name: "support", + }); - const result = await base44.agents.getConversation("conv-1"); - expect(result).toEqual(mockConversation); + 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: [] }; - scope.post(`/api/apps/${appId}/agents/conversations`).reply(200, created); + test("addMessage() posts to v2 and updates conversation state", async () => { + const conversation = { id: "conv-1", agent_name: "support", messages: [] }; + platform.given + .app(appId) + .agents.conversationsForUser(userId, [conversation]); + + const message = await base44.agents.addMessage(conversation, { + role: "user", + content: "Hi", + }); - const result = await base44.agents.createConversation({ agent_name: "support" }); - expect(result).toEqual(created); + 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("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!" }; - scope.post(`/api/apps/${appId}/agents/conversations/v2/conv-1/messages`).reply(200, response); + test("getWhatsAppConnectURL omits the token when unauthenticated", () => { + const anonymous = createClient({ serverUrl, appId }); + expect(anonymous.agents.getWhatsAppConnectURL("support")).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/whatsapp`, + ); + anonymous.cleanup(); + }); - const result = await base44.agents.addMessage(conversation, { role: "user", content: "Hi" }); - expect(result).toEqual(response); - }); + 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(); }); - 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 encodes the agent name", () => { + expect(base44.agents.getWhatsAppConnectURL("my agent")).toBe( + `${serverUrl}/api/apps/${appId}/agents/my%20agent/whatsapp?token=${userToken}`, + ); + }); - 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`); - }); + test("getTelegramConnectURL omits the token when unauthenticated", () => { + const anonymous = createClient({ serverUrl, appId }); + expect(anonymous.agents.getTelegramConnectURL("support")).toBe( + `${serverUrl}/api/apps/${appId}/agents/support/telegram`, + ); + anonymous.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("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("getTelegramConnectURL encodes the agent name", () => { + expect(base44.agents.getTelegramConnectURL("my agent")).toBe( + `${serverUrl}/api/apps/${appId}/agents/my%20agent/telegram?token=${userToken}`, + ); }); - 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("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("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`); + 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("should encode agent name", () => { - const url = base44.agents.getTelegramConnectURL("my agent"); - expect(url).toBe(`${serverUrl}/api/apps/${appId}/agents/my%20agent/telegram`); + 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(); + + 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 48e61f60..ff18fa42 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -8,7 +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 { AxiosInstance } from "axios"; +import { platform } from "../mocks/platform"; describe("Analytics Module", () => { let base44: ReturnType; @@ -22,22 +22,9 @@ 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) - ), - })); + platform.given + .app(appId) + .auth.principal("test-access-token", { id: "test-user-id" }); sharedState = getSharedInstance("analytics", () => ({ requestsQueue: [], isProcessing: false, @@ -66,9 +53,18 @@ 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; }); @@ -139,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(). @@ -218,24 +214,35 @@ 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}` }); } 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, + }); + + 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 ac4eb6c8..3ec88e60 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -1,79 +1,58 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import nock from "nock"; 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; - let scope: nock.Scope; beforeEach(() => { + platform.given + .app(appId) + .deployment.deploymentAccess("public_without_login"); base44 = createClient({ serverUrl, appId, token }); - scope = nock(serverUrl); }); - afterEach(() => { - nock.cleanAll(); - }); + afterEach(() => 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" }); - - const settings = await base44.app.getPublicSettings(); - - expect(settings).toEqual({ + await expect(base44.app.getPublicSettings()).resolves.toEqual({ 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" }); - await base44.app.getPublicSettings(); - - expect(scope.isDone()).toBe(true); + 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 }); - - scope - .get(publicSettingsPath) - .matchHeader("Authorization", (value) => value === undefined) - .reply(200, { id: appId, public_settings: "public_without_login" }); - await anonymous.app.getPublicSettings(); - - expect(scope.isDone()).toBe(true); + 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) => { - scope - .get(publicSettingsPath) - .reply(403, { extra_data: { app_id: appId, reason } }); - + 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 new file mode 100644 index 00000000..cfabd8b4 --- /dev/null +++ b/tests/unit/auth-registration.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createClient } from "../../src/index"; +import { platform } from "../mocks/platform"; + +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", + }; + platform.given.app(appId).auth.registrationChallenge(payload.email, { + userId: "new-user-id", + otpTtlMinutes: 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", + }; + platform.given.app(appId).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 () => { + 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 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", + newPassword: "test-new-password", + }), + ).toEqual({ + id: "reset-user-id", + app_id: appId, + 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", + }); + 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.app(appId).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", + }); + 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 c79df86b..74125886 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -1,27 +1,39 @@ -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 { 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) => { + 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"; + + 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 - 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 +43,104 @@ 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' - }; - - // Mock the API response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(200, mockUser); - + id: "user-123", + email: "test@example.com", + name: "Test User", + role: "user", + }; + + authenticate(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 () => { - // Mock the API error response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(401, { detail: 'Unauthorized' }); - + expect(result.id).toBe("user-123"); + expect(result.email).toBe("test@example.com"); + }); + + test("preserves authentication error status", async () => { // Call the API and expect an error - await expect(base44.auth.me()).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + 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 anonymousSameApp = createClient({ serverUrl, appId }); + const invalidSameApp = createClient({ + serverUrl, + appId, + token: "unknown-token", + }); + 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(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, + }); + 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' }; + 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); + authenticate(mockUser); const [first, second] = await Promise.all([ base44.auth.me(), @@ -105,190 +149,217 @@ describe('Auth Module', () => { expect(first).toEqual(mockUser); expect(second).toEqual(mockUser); - expect(scope.isDone()).toBe(true); + expect(platform.requests.count("auth.me")).toBe(1); }); - 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 () => { + 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. - 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" }; + 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); - - 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 () => { + 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); + 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); - }); - - 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' }); - // 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' }); + expect(afterLogin.id).toBe("logged-in"); + await expect(beforeLogin).resolves.toEqual({ id: "anonymous" }); + }); + + test("a superseded request does not retire the current one", async () => { + 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); + 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" }); + expect(platform.requests.count("auth.me")).toBe(2); }); - 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("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', - 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); - + + authenticate({ + id: "user-123", + name: "Original Name", + email: "original@example.com", + role: "user", + }); + // 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"); + expect(platform.requests.last("auth.updateMe").body).toEqual(updateData); + }); + + 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' }); - + + 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.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + await expect(base44.auth.updateMe(invalidData)).rejects.toMatchObject({ + status: 400, + message: "Invalid email format", + }); + expect(platform.requests.last("auth.updateMe").body).toEqual(invalidData); }); }); - - 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 +368,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 +394,127 @@ 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 () => { - // 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' }); - + + describe("logout()", () => { + test("should remove token from axios headers", async () => { + authenticate( + { + id: "user-123", + email: "test@example.com", + }, + "test-token", + ); + // Verify token is set by making a request await base44.auth.me(); - expect(scope.isDone()).toBe(true); - + 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 - 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) await expect(base44.auth.me()).rejects.toThrow(); - expect(scope.isDone()).toBe(true); + expect( + platform.requests.last("auth.me").headers.authorization, + ).toBeUndefined(); }); - - 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 +524,344 @@ 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' }); - + + platform.given.app(appId).auth.principal(token, { + id: "user-123", + email: "test@example.com", + }); + // Verify token is set by making a request await base44.auth.me(); - expect(scope.isDone()).toBe(true); + expect(platform.requests.last("auth.me").headers.authorization).toBe( + `Bearer ${token}`, + ); }); - - 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); - - // 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' }); - + + test("should handle empty token gracefully", async () => { + base44.auth.setToken("", false); + // Verify no Authorization header is sent (should throw 401) await expect(base44.auth.me()).rejects.toThrow(); - expect(scope.isDone()).toBe(true); + expect( + platform.requests.last("auth.me").headers.authorization, + ).toBeUndefined(); }); - - 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' - } - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(200, mockResponse); - + id: "user-123", + email: "test@example.com", + name: "Test User", + }, + }; + + platform.given.app(appId).auth.account({ + email: loginData.email, + password: loginData.password, + accessToken: mockResponse.access_token, + user: mockResponse.user, + }); + // 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"); + expect(platform.requests.last("auth.login").body).toEqual(loginData); + // 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' }); - await base44.auth.me(); - expect(scope.isDone()).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + expect(platform.requests.last("auth.me").headers.authorization).toBe( + "Bearer test-access-token", + ); }); - - 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' - } - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(200, mockResponse); - + id: "user-123", + email: "test@example.com", + }, + }; + + platform.given.app(appId).auth.account({ + email: loginData.email, + password: loginData.password, + accessToken: mockResponse.access_token, + user: mockResponse.user, + }); + // 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"); + expect(platform.requests.last("auth.login").body).toEqual(loginData); + // 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' }); - await base44.auth.me(); - expect(scope.isDone()).toBe(true); + 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 - scope.post(`/api/apps/${appId}/auth/login`, loginData) - .reply(401, { detail: 'Invalid credentials' }); - + email: "test@example.com", + password: "wrongpassword", + }; + + platform.given.app(appId).faults.auth.invalidCredentials(loginData.email); + // 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) - ).rejects.toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + base44.auth.loginViaEmailPassword(loginData.email, loginData.password), + ).rejects.toMatchObject({ + status: 400, + message: "Invalid credentials", + }); + expect(platform.requests.last("auth.login").body).toEqual(loginData); }); - - test('should handle network errors', async () => { + + 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'); - + email: "test@example.com", + password: "password123", + }; + + platform.given + .app(appId) + .faults.auth.networkUnavailableLogin(loginData.email); + // 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); + expect(platform.requests.last("auth.login").body).toEqual(loginData); }); }); - - 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' - }; - - // Mock the API response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(200, mockUser); - + id: "user-123", + email: "test@example.com", + }; + + authenticate(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 () => { - // Mock the API error response - scope.get(`/api/apps/${appId}/entities/User/me`) - .reply(401, { detail: 'Unauthorized' }); - + }); + + test("should return false when token is invalid", async () => { // 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 () => { - // Mock network error - scope.get(`/api/apps/${appId}/entities/User/me`) - .replyWithError('Network error'); + + test("should return false on network errors", async () => { + 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(); // 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 +876,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 +907,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 +925,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 +946,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 +984,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 +1020,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..56898688 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -1,50 +1,71 @@ -import { createClient, createClientFromRequest } from '../../src/index.ts'; -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; +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) => { + const client = newClient(...args); + clients.push(client); + return client; +}; +const createClientFromRequest = (...args) => { + const client = newClientFromRequest(...args); + clients.push(client); + return client; +}; +beforeEach(() => platform.reset()); +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 +78,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 +96,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 +151,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, @@ -351,108 +355,96 @@ describe('Service Role Authorization Headers', () => { 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 }); + platform.given.app(appId).entities.records("Todo", []); // Make requests await client.entities.Todo.list(); await client.asServiceRole.entities.Todo.list(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + 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 () => { - 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, 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' }); + platform.given + .app(appId) + .entities.records("User", [{ 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"); + expect(platform.requests.last("entities.get").headers.authorization).toBe( + `Bearer ${serviceToken}`, + ); }); - 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, 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' }); + platform.given.app(appId).integrations.emailDelivered("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"); + expect( + platform.requests.last("integrations.invoke").headers.authorization, + ).toBe(`Bearer ${serviceToken}`); }); - 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, 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' }); + platform.given.app(appId).functions.serviceExecution("testFunction"); // 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"); + 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 () => { - 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, @@ -460,230 +452,211 @@ describe('Service Role Authorization Headers', () => { 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' }); + platform.given + .app(appId) + .entities.records("Task", [{ id: "task1", title: "User Task" }]); + platform.given.app(appId).integrations.emailDelivered("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[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"); + 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 () => { + 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 }); + platform.given + .app(appId) + .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'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + 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 () => { - 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 }); + platform.given.app(appId).entities.records("Todo", []); // Make request await client.entities.Todo.list(); - - // Verify all mocks were called (including header match) - expect(scope.isDone()).toBe(true); + 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 () => { + 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 }); + platform.given.app(appId).entities.records("Todo", []); await client.entities.Todo.list(); - - expect(scope.isDone()).toBe(true); + 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 () => { + 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 }); + platform.given.app(appId).entities.records("Todo", []); await client.entities.Todo.list(); - - expect(scope.isDone()).toBe(true); + 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 () => { + 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 }); + platform.given.app(appId).entities.records("Todo", []); await client.entities.Todo.list(); - - expect(scope.isDone()).toBe(true); + 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 () => { + 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 }); + platform.given.app(appId).entities.records("Todo", []); // Make request await client.entities.Todo.list(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + 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 () => { - 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' }); + 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'); + 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(scope.isDone()).toBe(true); + expect(result.id).toBe("123"); + expect(platform.requests.last("entities.get").headers).toMatchObject({ + authorization: "Bearer service-token-123", + "base44-state": clientIp, + }); }); - -}); \ No newline at end of file +}); diff --git a/tests/unit/connectors-proxy.test.ts b/tests/unit/connectors-proxy.test.ts index e2df1ed7..80b4ac8a 100644 --- a/tests/unit/connectors-proxy.test.ts +++ b/tests/unit/connectors-proxy.test.ts @@ -1,87 +1,82 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform/index.ts"; + +const appId = "test-app-id"; +const serviceToken = "service-token-123"; describe("Connectors module – metered connector proxy", () => { - const appId = "test-app-id"; - 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(); + platform.given + .app(appId) + .auth.servicePrincipal(serviceToken, { id: "service-principal" }); + base44 = createClient({ + serverUrl: "https://base44.app", + appId, + serviceToken, + }); + platform.given.app(appId).connectors.socialApi("x", { id: "account-a" }); }); - - 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; - scope - .post(`/api/apps/${appId}/connectors/x/call`, (body) => { - received = body; - return true; - }) - .reply(200, proxyResponse); - 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: {}, + }, + }); + }); + + 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, + }); - 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({}); + 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 () => { - // 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); - - const res = await base44.asServiceRole.connectors.callApi( + platform.given.app(appId).connectors.echoApi("../evil/route"); + const result = await base44.asServiceRole.connectors.callApi( "../evil/route" as any, - { path: "/x" } + { path: "/x" }, + ); + expect(result.success).toBe(true); + expect(platform.requests.last("connectors.callApi").url).toContain( + "connectors/..%2Fevil%2Froute/call", ); - - expect(res.success).toBe(true); }); 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[] = []; - scope - .post(`/api/apps/${appId}/connectors/googlemaps/call`, (body) => { - bodies.push(body); - return true; - }) - .times(3) - .reply(200, proxyResponse); - + 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", @@ -93,147 +88,167 @@ describe("Connectors module – metered connector proxy", () => { 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 () => { - 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, + platform.given.app(appId).connectors.mapsApi("googlemaps", { + bytes: [137, 80, 78, 71, 13, 10, 26, 10], + contentType: "image/png", }); - - const res = await base44.asServiceRole.connectors.callApi("googlemaps", { + const result = await base44.asServiceRole.connectors.callApi("googlemaps", { path: "/maps/api/staticmap", }); - - expect(res.dataBase64).toBe("iVBORw0KGgo="); - expect(res.contentType).toBe("image/png"); - expect(res.data).toBeNull(); + expect(result).toMatchObject({ + dataBase64: "iVBORw0KGgo=", + contentType: "image/png", + data: null, + }); }); test("leaves dataBase64 and contentType null for a JSON response", async () => { - scope.post(`/api/apps/${appId}/connectors/x/call`).reply(200, proxyResponse); - - const res = await base44.asServiceRole.connectors.callApi("x", { + const result = await base44.asServiceRole.connectors.callApi("x", { path: "/2/users/me", }); - - expect(res.dataBase64).toBeNull(); - expect(res.contentType).toBeNull(); + expect(result.dataBase64).toBeNull(); + expect(result.contentType).toBeNull(); }); test("defaults the method to GET", async () => { - let received: any; - scope - .post(`/api/apps/${appId}/connectors/x/call`, (body) => { - received = body; - return true; - }) - .reply(200, 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; - scope - .post(`/api/apps/${appId}/connectors/x/call`, (body) => { - received = body; - return true; - }) - .reply(200, proxyResponse); - + const query = { query: "base44", max_results: 10 }; await base44.asServiceRole.connectors.callApi("x", { path: "/2/tweets/search/recent", - query: { query: "base44", max_results: 10 }, + query, + }); + expect(platform.requests.last("connectors.callApi").body).toMatchObject({ + query, }); - - expect(received.query).toEqual({ query: "base44", max_results: 10 }); }); test("maps the proxy envelope to camelCase", async () => { - scope.post(`/api/apps/${appId}/connectors/x/call`).reply(200, proxyResponse); - - const res = await base44.asServiceRole.connectors.callApi("x", { + 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", 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" }] }, }); + }); - 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("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.socialApi("x", { id: "account-b" }); + const otherClient = createClient({ + serverUrl: "https://base44.app", + appId: otherAppId, + serviceToken: otherServiceToken, + }); + + await expect( + base44.asServiceRole.connectors.callApi("x", { path: "/scope" }), + ).resolves.toMatchObject({ + status: 200, + data: { data: { id: "account-a" } }, + }); + await expect( + otherClient.asServiceRole.connectors.callApi("x", { path: "/scope" }), + ).resolves.toMatchObject({ + status: 200, + data: { data: { id: "account-b" } }, + }); + otherClient.cleanup(); }); 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, { + platform.given.app(appId).faults.connectors.upstreamRejected("x"); + const result = await base44.asServiceRole.connectors.callApi("x", { + method: "POST", + path: "/2/tweets", + body: {}, + }); + expect(result).toMatchObject({ success: false, phase: "responded", - status_code: 400, + status: 400, data: { title: "Invalid Request" }, - headers: {}, - credits_charged: 3, + creditsCharged: 3, }); + }); - const res = await base44.asServiceRole.connectors.callApi("x", { - method: "POST", - path: "/2/tweets", - body: {}, + 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, }); - - 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. - 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" }, - }); - + 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", { + method: "POST", + path: "/2/tweets", + body: { text: "recovered" }, + }), + ).resolves.toMatchObject({ + success: true, + status: 201, + }); }); 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, - { - 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" } - ); - + platform.given.app(appId).faults.connectors.meteredTokenRequiresProxy("x"); await expect( - base44.asServiceRole.connectors.getConnection("x") + base44.asServiceRole.connectors.getConnection("x"), ).rejects.toMatchObject({ status: 403, code: "metered_connector_requires_proxy", @@ -248,33 +263,31 @@ 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", ); - } + 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) => { - 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, - }); - - const res = await base44.asServiceRole.connectors.callApi("x", { + 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(res.phase).toBe(phase); - expect(res.status).toBeNull(); - expect(res.success).toBe(false); - } + expect(result).toMatchObject({ + phase, + status: null, + success: false, + creditsCharged: phase === "not_sent" ? 0 : 3, + }); + }, ); test.each([ @@ -282,7 +295,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..92b47aeb 100644 --- a/tests/unit/connectors.test.ts +++ b/tests/unit/connectors.test.ts @@ -1,277 +1,380 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import nock from "nock"; import { createClient } from "../../src/index.ts"; -import type { AppUserConnectorConnectionResponse } from "../../src/modules/connectors.types.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", () => { - const appId = "test-app-id"; - const serverUrl = "https://base44.app"; - const serviceToken = "service-token-123"; let base44: ReturnType; - let scope: nock.Scope; - beforeEach(() => { + arrangeServicePrincipal(); base44 = createClient({ - serverUrl, + serverUrl: "https://base44.app", appId, serviceToken, }); - scope = nock(serverUrl); }); - - afterEach(() => { - nock.cleanAll(); - }); - - 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" - ); - - expect(connection).toBeDefined(); - expect(connection.accessToken).toBe("oauth-token-abc123"); - expect(connection.connectionConfig).toEqual({ - subdomain: "my-company", + afterEach(() => base44.cleanup()); + + test("extracts accessToken and connectionConfig", async () => { + 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" }, }); - expect(scope.isDone()).toBe(true); - }); - - 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" - ); - - 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" - ); - - expect(connection.accessToken).toBe("token-only"); - expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); }); - test("throws when integrationType is empty string", async () => { + test.each([ + ["slack", undefined], + ["github", 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("") + 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("throws when integrationType is not a string", async () => { + 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( - null as unknown as string - ) - ).rejects.toThrow("Integration type is required and must be a string"); + 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", () => { - const appId = "test-app-id"; - const serverUrl = "https://base44.app"; - const serviceToken = "service-token-123"; let base44: ReturnType; - let scope: nock.Scope; - beforeEach(() => { + arrangeServicePrincipal(); base44 = createClient({ - serverUrl, + serverUrl: "https://base44.app", appId, serviceToken, }); - scope = nock(serverUrl); - }); - - afterEach(() => { - nock.cleanAll(); }); - - 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" }, - }; - - scope - .get(`/api/apps/${appId}/external-auth/tokens/connectors/connector-abc`) - .reply(200, apiResponse); - - const connection = - await base44.asServiceRole.connectors.getWorkspaceConnection( - "connector-abc" + afterEach(() => base44.cleanup()); + + test("extracts accessToken and connectionConfig", async () => { + platform.given + .app(appId) + .connectors.workspaceConnection( + "connector-abc", + "snowflake", + "builder-oauth-token-xyz789", + { subdomain: "xy12345.us-east-1" }, ); - - expect(connection.accessToken).toBe("builder-oauth-token-xyz789"); - expect(connection.connectionConfig).toEqual({ - 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" }, }); - expect(scope.isDone()).toBe(true); }); - test("returns connectionConfig as null when API omits connection_config", async () => { - const apiResponse = { - access_token: "token-only", - integration_type: "databricks", - }; - - scope - .get(`/api/apps/${appId}/external-auth/tokens/connectors/conn-2`) - .reply(200, 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("returns null when connection_config is omitted", async () => { + 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("throws when connectorId is empty string", async () => { + 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("") - ).rejects.toThrow("Connector ID is required and must be a string"); + 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("throws when connectorId is not a string", async () => { + test.each(["", null])("rejects invalid connector ID %s", async (id) => { await expect( base44.asServiceRole.connectors.getWorkspaceConnection( - null as unknown as string - ) + 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; - let scope: nock.Scope; - beforeEach(() => { + arrangeServicePrincipal(); + platform.given.app(appId).auth.principal(userToken, { id: userId }); base44 = createClient({ - serverUrl, + serverUrl: "https://base44.app", appId, + token: userToken, serviceToken, }); - scope = nock(serverUrl); }); - - afterEach(() => { - nock.cleanAll(); - }); - - 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" }, - }; - - scope - .get(`/api/apps/${appId}/app-user-auth/connectors/connector-1/token`) - .reply(200, apiResponse); - - const connection: AppUserConnectorConnectionResponse = - await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-1" + afterEach(() => base44.cleanup()); + + test("extracts accessToken and connectionConfig", async () => { + platform.given + .app(appId) + .connectors.appUserConnection( + userId, + "connector-1", + "jira", + "user-oauth-token-abc123", + { subdomain: "my-company" }, ); - - expect(connection).toBeDefined(); - expect(connection.accessToken).toBe("user-oauth-token-abc123"); - expect(connection.connectionConfig).toEqual({ - 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, }); - expect(scope.isDone()).toBe(true); + await expect( + userAsService.asServiceRole.connectors.getCurrentAppUserConnection( + "connector-1", + ), + ).rejects.toMatchObject({ + status: 403, + message: "This endpoint is only accessible to service tokens", + }); + userAsService.cleanup(); }); - test("returns connectionConfig as null when API omits connection_config", async () => { - const apiResponse = { - access_token: "user-token-only", - integration_type: "slack", - }; - - scope - .get(`/api/apps/${appId}/app-user-auth/connectors/connector-2/token`) - .reply(200, apiResponse); - - const connection: AppUserConnectorConnectionResponse = - await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-2" + test.each([ + ["connector-2", "slack", undefined], + ["connector-3", "github", null], + ])("returns null config for %s", async (id, type, config) => { + platform.given + .app(appId) + .connectors.appUserConnection( + userId, + id, + type, + "user-token-only", + config, ); - - expect(connection.accessToken).toBe("user-token-only"); - expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); + await expect( + base44.asServiceRole.connectors.getCurrentAppUserConnection(id), + ).resolves.toEqual({ + accessToken: "user-token-only", + connectionConfig: null, + }); }); - 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, - }; - - scope - .get(`/api/apps/${appId}/app-user-auth/connectors/connector-3/token`) - .reply(200, apiResponse); - - const connection: AppUserConnectorConnectionResponse = - await base44.asServiceRole.connectors.getCurrentAppUserConnection( - "connector-3" - ); - - expect(connection.accessToken).toBe("user-token-only"); - expect(connection.connectionConfig).toBeNull(); - expect(scope.isDone()).toBe(true); + 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"); }); - test("throws when connectorId is empty string", async () => { + 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.asServiceRole.connectors.getCurrentAppUserConnection("") - ).rejects.toThrow("Connector ID is required and must be a string"); + base44.connectors.disconnectAppUser("connector-1"), + ).resolves.toBeUndefined(); + await expect( + base44.asServiceRole.connectors.getCurrentAppUserConnection( + "connector-1", + ), + ).rejects.toMatchObject({ status: 404 }); }); - test("throws when connectorId is not a string", async () => { + 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( - null as unknown as string - ) - ).rejects.toThrow("Connector ID is required and must be a string"); + "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 ab34bea5..3aa901a5 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -1,360 +1,246 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; +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', () => { +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"; 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(); - }); - - 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' }, - }; - - // 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); + 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 + .app(appId) + .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, + { + 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); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + 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" }, + 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); - - // 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); + test("works with empty params", async () => { + platform.given.app(appId).customIntegrations.githubUser("github", { + 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 - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) - .reply(404, { - 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({ + test("maps missing integration to a 404 Base44Error", async () => { + await expect( + base44.integrations.custom.call("nonexistent", "someEndpoint"), + ).rejects.toMatchObject({ status: 404, - name: 'Base44Error', + name: "Base44Error", + message: "Custom integration 'nonexistent' not found in workspace", }); - - // 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}'`, - }); - - // Call the API and expect an error - await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + test("maps missing operation to a 404 Base44Error", async () => { + platform.given + .app(appId) + .customIntegrations.operationAvailable("github", "existingOperation"); + await expect( + base44.integrations.custom.call("github", "nonExistentOperation"), + ).rejects.toMatchObject({ status: 404, - name: 'Base44Error', + name: "Base44Error", + message: + "Operation 'nonExistentOperation' not found in integration 'github'", }); - - // 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', - }); - - // Call the API and expect an error - await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + test("rejects a Base44 connection failure while preserving later recovery", async () => { + const operationId = "get:/repos/{owner}/{repo}/issues"; + platform.given + .app(appId) + .customIntegrations.githubRepository( + "github", + "testuser", + "testrepo", + [], + ); + platform.given + .app(appId) + .faults.customIntegrations.upstreamUnavailable("github", operationId); + await expect( + base44.integrations.custom.call("github", operationId), + ).rejects.toMatchObject({ status: 502, - name: 'Base44Error', + message: "Failed to connect to external API: Connection refused", }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - 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 () => { - 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' - ); - }); - - 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 = { + await expect( + base44.integrations.custom.call("github", operationId), + ).resolves.toEqual({ 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); - - // Call the API with large payload - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + data: { issues: [] }, + }); + }); - // Verify the response - expect(result.success).toBe(true); + 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( + // @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}` }, + })); + platform.given.app(appId).customIntegrations.inventory("myapi"); + const result = await base44.integrations.custom.call( + "myapi", + "bulkCreate", + { payload: { items } }, + ); expect(result.data.created).toBe(1000); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + await expect( + base44.integrations.custom.call("myapi", "listItems"), + ).resolves.toMatchObject({ + data: { items }, + }); + expect(platform.requests.all("customIntegrations.call")[0].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 - 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); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + test("includes custom headers in the backend request body", async () => { + const headers = { "X-Custom-Header": "custom-value" }; + platform.given.app(appId).customIntegrations.requestInspector("myapi"); + 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 - 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); + platform.given + .app(appId) + .customIntegrations.apiKeyProtected("myapi", "secret-key-123"); + const result = await base44.integrations.custom.call( + "myapi", + "secureEndpoint", + { headers }, + ); expect(result.data.authenticated).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).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'; - 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); - - const result = await base44.integrations.custom.call(slug, operationId, sdkParams); - - expect(result.success).toBe(true); - expect(scope.isDone()).toBe(true); + test("only includes defined params in body", async () => { + const operationId = "get:/users/{username}"; + platform.given.app(appId).customIntegrations.githubUser("github", { + 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 should not interfere with other integration packages', async () => { - // Test that Core still works - const coreParams = { - 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); - expect(packageResult.success).toBe(true); + test("custom property does not interfere with other integration packages", async () => { + platform.given.app(appId).integrations.emailDelivered(); + // Legacy SDK compatibility; current Apper has removed this route. + platform.given + .app(appId) + .integrations.legacyEndpoint("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.workspaceIdentity("github"); + platform.given + .app(isolatedAppId) + .customIntegrations.workspaceIdentity("github"); + const isolated = createClient({ + serverUrl: "https://base44.app", + appId: isolatedAppId, + }); + const shared = createClient({ + serverUrl: "https://base44.app", + appId: sharedAppId, + }); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + await expect( + base44.integrations.custom.call("github", "whoami"), + ).resolves.toMatchObject({ + data: { workspaceId: "workspace-a" }, + }); + await expect( + isolated.integrations.custom.call("github", "whoami"), + ).resolves.toMatchObject({ + data: { workspaceId: "workspace-b" }, + }); + await expect( + shared.integrations.custom.call("github", "whoami"), + ).resolves.toMatchObject({ + data: { workspaceId: "workspace-a" }, + }); + isolated.cleanup(); + shared.cleanup(); }); }); 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..affe6298 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -1,19 +1,15 @@ 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 { platform } from "../mocks/platform"; -/** - * Todo entity type for testing. - */ 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; @@ -22,305 +18,340 @@ 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"; beforeEach(() => { - // Create a new client for each test + platform.reset(); base44 = createClient({ - serverUrl, + serverUrl: "https://api.base44.com", 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(); - }); + 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.app(appId).entities.records("Todo", [ { id: "1", title: "Task 1", completed: false }, { 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); + ]); - // 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"); + 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", + }); + }); + + test("list() retains id when a field projection omits it", async () => { + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "1", title: "Projected", completed: false }, + ]); + + await expect( + base44.entities.Todo.list(undefined, undefined, undefined, ["title"]), + ).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 }, + ]); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + 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("filter() should send correct query parameters", async () => { - const filterQuery: Partial = { completed: true }; - const mockTodos: Todo[] = [{ id: "2", title: "Task 2", completed: true }]; + 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 }, + ]); - // 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; - }) - .reply(200, mockTodos); + 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", + }); + }); - // Call the API - const result = await base44.entities.Todo.filter(filterQuery); + 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" }, + ]); - // Verify the response - expect(result).toHaveLength(1); - expect(result[0].completed).toBe(true); + 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" }, + ]); + }); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + 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 }, + { 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, + }, + ); }); - 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) => { - 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 - ); - }) - .reply(200, mockTodos); - - const result = await base44.entities.Todo.filter({ + test("filter() supports typed advanced query syntax", async () => { + 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 }, + ]); + 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(scope.isDone()).toBe(true); + 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 + .app(appId) + .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 - scope.get(`/api/apps/${appId}/entities/Todo/${todoId}`).reply(200, 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 = { + test("create() persists so subsequent get() and list() observe the record", async () => { + platform.given.app(appId).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 - scope - .post(`/api/apps/${appId}/entities/Todo`, newTodo as nock.RequestBodyMatcher) - .reply(201, createdTodo); + }); + }); - // Call the API - const todo = await base44.entities.Todo.create(newTodo); + 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, + }); + 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(); + }); - // Verify the response - expect(todo.id).toBe("123"); - expect(todo.title).toBe("New task"); + test("update() changes the stored entity", async () => { + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "123", title: "Old task", completed: false }, + ]); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); + const updated = await base44.entities.Todo.update("123", { + title: "Updated task", + completed: true, + }); - test("update() should send correct data", async () => { - const todoId = "123"; - const updates: Partial = { + expect(updated).toEqual({ + id: "123", title: "Updated task", completed: true, - }; - const updatedTodo: Todo = { - id: todoId, + }); + 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 - 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 - 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); + test("delete() removes the stored entity and returns DeleteResult", async () => { + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "123", title: "Delete me", completed: false }, + ]); - // 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 = { + await expect(base44.entities.Todo.delete("123")).resolves.toEqual({ success: true, - updated: 3, - has_more: false, - }; + }); + await expect(base44.entities.Todo.list()).resolves.toEqual([]); + }); - // Mock the API response - scope - .patch(`/api/apps/${appId}/entities/Todo/update-many`, { - query: { completed: false }, - data: { $set: { completed: true } }, - }) - .reply(200, mockResult); + test("updateMany() applies update operators to matching records", async () => { + 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 }, + { id: "4", title: "Done", completed: true }, + ]); - // 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); + 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 - scope - .patch(`/api/apps/${appId}/entities/Todo/update-many`, { - query: {}, - data: { $inc: { view_count: 1 } }, - }) - .reply(200, mockResult); + test("updateMany() reports has_more at the platform batch limit", async () => { + platform.given.app(appId).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 } } + { $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); + 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.app(appId).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 }, - ]; - - // Mock the API response - scope - .put( - `/api/apps/${appId}/entities/Todo/bulk`, - updatePayload as nock.RequestBodyMatcher - ) - .reply(200, mockResponse); - - // Call the API - const result = await base44.entities.Todo.bulkUpdate(updatePayload); - - // 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); + ]); + expect(platform.requests.last("entities.bulkUpdate").body).toEqual(updates); }); + test("reset() isolates platform state and request history", async () => { + platform.given + .app(appId) + .entities.records("Todo", [ + { id: "1", title: "Transient", completed: false }, + ]); + await base44.entities.Todo.list(); + + platform.reset(); + + 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 73167ff2..f7bfe139 100644 --- a/tests/unit/fetch-with-auth.test.ts +++ b/tests/unit/fetch-with-auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { createClient, createClientFromRequest } from "../../src/index.ts"; +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"; @@ -35,29 +36,41 @@ 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]> = []; +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; + 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(); vi.unstubAllGlobals(); vi.clearAllMocks(); }); - const lastCall = () => { - const [url, init] = fetchMock.mock.calls[0]; - return { url, init, headers: new Headers(init.headers) }; + const requests = platform.requests.all("generic.request"); + expect(requests).toHaveLength(1); + const [url, init] = transportCalls[0]!; + return { url, init, request: requests[0]!, headers: new Headers(requests[0]!.headers) }; }; describe("fetchWithAuth", () => { @@ -110,7 +123,7 @@ describe("fetchWithAuth", () => { await base44.fetchWithAuth("/api/public"); expect(lastCall().headers.get("Authorization")).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(platform.requests.count("generic.request")).toBe(1); }); test("forwards init options and keeps a caller-set Authorization header", async () => { @@ -129,6 +142,7 @@ describe("fetchWithAuth", () => { const { init, headers } = lastCall(); expect(init.method).toBe("POST"); expect(init.body).toBe(JSON.stringify({ 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"); }); @@ -160,7 +174,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(platform.requests.count("generic.request")).toBe(0); }); test("rejects an empty path", async () => { @@ -168,7 +183,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(platform.requests.count("generic.request")).toBe(0); }); test("works with no document, as in a server route", async () => { @@ -188,7 +204,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(platform.requests.count("generic.request")).toBe(0); }); }); @@ -220,7 +237,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 +252,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 +262,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 +270,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 +280,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 +298,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 +310,7 @@ describe("fetchWithAuth from a server route", () => { const base44 = createClientFromRequest(inboundRequest()); await base44.fetchWithAuth("/api/items", { - fetch: fetchMock, + fetch: transport, headers: { Authorization: "" }, }); @@ -301,12 +318,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(platform.requests.count("generic.request")).toBe(1); expect(lastCall().init).not.toHaveProperty("fetch"); }); @@ -314,9 +331,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(platform.requests.count("generic.request")).toBe(0); }); }); diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index 9a55379b..62de6b7c 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -1,5 +1,5 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; -import nock from "nock"; +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 @@ -13,8 +13,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 +22,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 () => { @@ -56,14 +36,7 @@ describe("Functions Module", () => { 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", - }); + platform.given.app(appId).functions.notificationDelivery("msg-456"); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -71,31 +44,26 @@ 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); + 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"; - // 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", - }); + platform.given.app(appId).functions.serviceHealth("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); + expect(platform.requests.last("functions.invoke").body).toEqual({}); }); test("should handle function with complex nested objects", async () => { @@ -117,23 +85,16 @@ describe("Functions Module", () => { }, }; - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { - processed: true, - userId: "123", - }); + platform.given.app(appId).functions.userProcessor(); // 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); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should handle file uploads with FormData", async () => { @@ -145,21 +106,7 @@ describe("Functions Module", () => { 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, - }, - ]; - }); + platform.given.app(appId).functions.fileStore(functionName, "file-789"); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -167,9 +114,17 @@ 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); + 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 () => { @@ -187,16 +142,9 @@ describe("Functions Module", () => { 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", - }); + platform.given + .app(appId) + .functions.documentProcessor("doc-123", "document content"); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -204,9 +152,23 @@ 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); + 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,15 +178,9 @@ describe("Functions Module", () => { 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, - }); + platform.given + .app(appId) + .functions.formSubmissions(functionName, "form-456"); // Call the function const result = await base44.functions.invoke(functionName, formData); @@ -232,9 +188,53 @@ 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" }, + ]); + }); - // 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", + }), + ); + 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( + (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], + }, + }, + ]); + 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 () => { @@ -243,9 +243,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`, ); }); @@ -255,22 +255,16 @@ describe("Functions Module", () => { input: "test data", }; - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { - processed: true, - }); + platform.given.app(appId).functions.userProcessor(functionName); // 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); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should handle API errors gracefully", async () => { @@ -279,22 +273,25 @@ describe("Functions Module", () => { 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", - }); + 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(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + base44.functions.invoke(functionName, functionData), + ).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 () => { @@ -303,22 +300,21 @@ describe("Functions Module", () => { 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", - }); + 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(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + base44.functions.invoke(functionName, functionData), + ).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, + ); }); test("should handle null and undefined values in data", async () => { @@ -330,23 +326,18 @@ describe("Functions Module", () => { emptyString: "", }; - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader("Content-Type", "application/json") - .reply(200, { - received: true, - values: functionData, - }); + platform.given.app(appId).functions.inputReceipt(functionName); // 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); + expect(platform.requests.last("functions.invoke").body).toEqual({ + stringValue: "test", + nullValue: null, + emptyString: "", + }); }); test("should handle array values in data", async () => { @@ -357,14 +348,7 @@ describe("Functions Module", () => { 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, - }); + platform.given.app(appId).functions.arrayProcessor(functionName); // Call the function const result = await base44.functions.invoke(functionName, functionData); @@ -372,9 +356,9 @@ 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); + expect(platform.requests.last("functions.invoke").body).toEqual( + functionData, + ); }); test("should create FormData correctly when files are present", async () => { @@ -386,20 +370,17 @@ describe("Functions Module", () => { category: "documents", }; - // Mock the API response - scope - .post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader("Content-Type", /^multipart\/form-data/) - .reply(200, { success: true }); + platform.given.app(appId).functions.uploadAcceptance(functionName); // 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); + 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 () => { @@ -408,20 +389,20 @@ describe("Functions Module", () => { 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 }); + platform.given.app(appId).functions.uploadAcceptance(functionName); // 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); + 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 () => { @@ -438,42 +419,63 @@ describe("Functions Module", () => { 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, - }); + platform.given.app(appId).functions.authenticatedProbe(functionName); // 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); + expect( + platform.requests.last("functions.invoke").headers.authorization, + ).toBe(`Bearer ${userToken}`); + 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"); - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + 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 () => { - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + platform.given.functions.legacyEndpoint("my_function"); - await base44.functions.fetch("/my_function", { - method: "GET", - }); + await base44.functions.fetch("/my_function", { method: "GET" }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( + expect(platform.requests.last("functions.fetch").url).toBe( `${serverUrl}/api/functions/my_function`, - expect.any(Object) ); }); - test("should include Authorization header when using functions.fetch", async () => { const userToken = "user-streaming-token"; const authenticatedBase44 = createClient({ @@ -481,51 +483,50 @@ describe("Functions Module", () => { appId, token: userToken, }); - fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + + platform.given.functions.legacyEndpoint("streaming_demo"); 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}`); + 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 () => { - // 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) - ); + platform.given.functions.legacyEndpoint("my_function"); - // 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( + + const calledUrls = platform.requests + .all("functions.fetch") + .map((request) => request.url); + expect(calledUrls).toEqual([ `${serverUrl}/api/functions/my_function`, - expect.any(Object) - ); + `${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 }); + + platform.given.functions.legacyEndpoint("service_function"); 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( + 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 159c47ee..0625a684 100644 --- a/tests/unit/integrations.test.js +++ b/tests/unit/integrations.test.js @@ -1,122 +1,124 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform/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 = 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' + afterEach(() => base44.cleanup()); + + test("Core integration sends named parameters to the endpoint", async () => { + platform.given.app(appId).integrations.emailDelivered("123456"); + const 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' }); - - // 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); + 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`, + }); }); - - test('Custom package integration should send requests to the correct endpoint', async () => { - const customParams = { - 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("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 + .app(appId) + .integrations.legacyEndpoint("CustomPackage", "CustomEndpoint"); + const params = { param1: "value1", param2: "value2" }; + const result = + await base44.integrations.CustomPackage.CustomEndpoint(params); + expect(result).toEqual({ success: true, received: params }); + expect(platform.requests.last("integrations.invoke")).toMatchObject({ + method: "POST", + body: params, + url: `${serverUrl}/api/apps/${appId}/integration-endpoints/installable/CustomPackage/integration-endpoints/CustomEndpoint`, + }); }); - - 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' } - }; - - // 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 serializes file uploads as multipart data", async () => { + 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" }, + }); + 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"}' }, + ]), + }, + }); + }); + + 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 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 maps a named invalid-parameters platform fault", async () => { + 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({ + success: true, + messageId: "after-retry", + }); }); - - 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)) - .rejects.toMatchObject({ - status: 400, - name: 'Base44Error', - message: 'Invalid parameters', - code: 'INVALID_PARAMS' - }); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); + + 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(); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/integrations.test.ts b/tests/unit/integrations.test.ts index 88fca9b2..57721643 100644 --- a/tests/unit/integrations.test.ts +++ b/tests/unit/integrations.test.ts @@ -1,75 +1,44 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; +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', () => { +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"; beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - }); - - scope = nock(serverUrl); - }); - - afterEach(() => { - nock.cleanAll(); + base44 = createClient({ serverUrl: "https://base44.app", appId }); }); - - test('InvokeLLM should pass model parameter to the API', async () => { - const params = { - prompt: 'Explain quantum computing', - model: 'gpt_5', - }; - - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, params) - .reply(200, 'Quantum computing uses qubits...'); - - const result = await base44.integrations.Core.InvokeLLM(params); - expect(result).toBe('Quantum computing uses qubits...'); - expect(scope.isDone()).toBe(true); + afterEach(() => base44.cleanup()); + + test("passes model parameter to the API", async () => { + const params = { prompt: "Explain quantum computing", model: "gpt_5" }; + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe( + "Mock LLM text response", + ); + expect(platform.requests.last("integrations.invoke").body).toEqual(params); }); - test('InvokeLLM should work without model parameter', async () => { - const params = { - prompt: 'Explain quantum computing', - }; - - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, params) - .reply(200, 'Quantum computing uses qubits...'); - - const result = await base44.integrations.Core.InvokeLLM(params); - expect(result).toBe('Quantum computing uses qubits...'); - expect(scope.isDone()).toBe(true); + test("works without model parameter", async () => { + const params = { prompt: "Explain quantum computing" }; + await expect(base44.integrations.Core.InvokeLLM(params)).resolves.toBe( + "Mock LLM text response", + ); + 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 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', - properties: { - sentiment: { type: 'string' }, - }, + type: "object", + properties: { sentiment: { type: "string" } }, }, }; - - const mockResponse = { sentiment: 'positive' }; - - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`, params) - .reply(200, mockResponse); - - const result = await base44.integrations.Core.InvokeLLM(params); - expect(result).toEqual(mockResponse); - expect(scope.isDone()).toBe(true); + 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 new file mode 100644 index 00000000..896d1777 --- /dev/null +++ b/tests/unit/mock-platform-architecture.test.ts @@ -0,0 +1,174 @@ +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(""), + ["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)) + .map((name) => `${unitDirectory}/${name}`) + .filter((path) => path !== thisFile) + .flatMap((path) => { + const source = readFileSync(path, "utf8"); + 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 04fe2c50..32e9eded 100644 --- a/tests/unit/sso.test.ts +++ b/tests/unit/sso.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import nock from "nock"; import { createClient } from "../../src/index.ts"; +import { platform } from "../mocks/platform"; describe("SSO module", () => { const appId = "test-app-id"; @@ -9,86 +9,49 @@ describe("SSO module", () => { const userToken = "user-token-456"; const userId = "user_123"; let base44: ReturnType; - let scope: nock.Scope; beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - token: userToken, - serviceToken, + platform.given.app(appId).sso.tokens(userId, { + idToken: "header.payload.signature", + accessToken: "access-token-123", }); - scope = nock(serverUrl); + base44 = createClient({ serverUrl, appId, token: userToken, serviceToken }); }); - afterEach(() => { - nock.cleanAll(); - }); + afterEach(() => 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), { - "Content-Type": "application/json", - }); - - const idToken: string = - await base44.asServiceRole.sso.getIdToken(userId); - - expect(idToken).toBe(rawIdToken); - expect(scope.isDone()).toBe(true); + 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"; - - scope - .get(`/api/apps/${appId}/auth/sso/accesstoken/${userId}`) - .reply(200, JSON.stringify(rawAccessToken), { - "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); - expect(scope.isDone()).toBe(true); + 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"; - - scope - .get(`/api/apps/${appId}/auth/sso/idtoken/${userId}`) - .matchHeader("Authorization", `Bearer ${serviceToken}`) - .matchHeader("on-behalf-of", `Bearer ${userToken}`) - .reply(200, JSON.stringify(rawIdToken), { - "Content-Type": "application/json", - }); - - const idToken = await base44.asServiceRole.sso.getIdToken(userId); - - expect(idToken).toBe(rawIdToken); - expect(scope.isDone()).toBe(true); + 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 () => { - scope - .get(`/api/apps/${appId}/auth/sso/idtoken/${userId}`) - .reply(404, { detail: "No ID token stored", code: "NOT_FOUND" }); - + platform.given + .app(appId) + .sso.tokens("another-user", { accessToken: "only-access" }); await expect( - base44.asServiceRole.sso.getIdToken(userId) + base44.asServiceRole.sso.getIdToken("another-user"), ).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"], + }, +});