From 39949ea9cd756e69b2c1406fb2ee8e2069855476 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 29 May 2026 23:12:29 +1000 Subject: [PATCH 01/10] chore: Allow TypeScript spec files via @babel/register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a narrow @babel/register hook (spec/support/tsRegister.js) loaded by Jasmine before helper.js so .ts spec/helper files are transpiled at require time. spec_files glob widened to discover .ts specs. Existing .js specs are unaffected — the hook only intercepts .ts under spec/. --- package-lock.json | 313 +++++++++++++++++++++++++++++++++++++ package.json | 1 + spec/support/jasmine.json | 4 +- spec/support/tsRegister.js | 13 ++ 4 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 spec/support/tsRegister.js diff --git a/package-lock.json b/package-lock.json index 55601800c1..1e979a52a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "@babel/plugin-transform-flow-strip-types": "7.27.1", "@babel/preset-env": "7.29.2", "@babel/preset-typescript": "7.27.1", + "@babel/register": "7.27.1", "@saithodev/semantic-release-backmerge": "4.0.1", "@semantic-release/changelog": "6.0.3", "@semantic-release/commit-analyzer": "13.0.1", @@ -2127,6 +2128,41 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/register": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.27.1.tgz", + "integrity": "sha512-K13lQpoV54LATKkzBpBAEu1GGSIRzxR9f4IN4V8DCDgiUMo2UDGagEZr3lPeVNJPLkWUi5JE4hCHKneVTwQlYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "find-cache-dir": "^2.0.0", + "make-dir": "^2.1.0", + "pirates": "^4.0.6", + "source-map-support": "^0.5.16" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/register/node_modules/find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -10387,6 +10423,21 @@ "node": ">=0.8" } }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -14506,6 +14557,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -14609,6 +14673,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "devOptional": true }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/issue-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", @@ -15201,6 +15275,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", @@ -20707,6 +20791,16 @@ "node": ">=0.10.0" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pkg-conf": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", @@ -20787,6 +20881,85 @@ "node": ">=4" } }, + "node_modules/pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-dir/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -24520,6 +24693,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -28236,6 +28422,32 @@ "@babel/plugin-transform-typescript": "^7.27.1" } }, + "@babel/register": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.27.1.tgz", + "integrity": "sha512-K13lQpoV54LATKkzBpBAEu1GGSIRzxR9f4IN4V8DCDgiUMo2UDGagEZr3lPeVNJPLkWUi5JE4hCHKneVTwQlYQ==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "find-cache-dir": "^2.0.0", + "make-dir": "^2.1.0", + "pirates": "^4.0.6", + "source-map-support": "^0.5.16" + }, + "dependencies": { + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + } + } + }, "@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -33900,6 +34112,17 @@ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, "cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -36756,6 +36979,15 @@ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, "is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -36826,6 +37058,12 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "devOptional": true }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true + }, "issue-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", @@ -37284,6 +37522,12 @@ "json-buffer": "3.0.1" } }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, "klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", @@ -41055,6 +41299,12 @@ "pinkie": "^2.0.0" } }, + "pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true + }, "pkg-conf": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", @@ -41116,6 +41366,60 @@ } } }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true + } + } + }, "pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -43574,6 +43878,15 @@ "to-buffer": "^1.2.0" } }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index c27d98205b..83ffc42791 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@babel/plugin-transform-flow-strip-types": "7.27.1", "@babel/preset-env": "7.29.2", "@babel/preset-typescript": "7.27.1", + "@babel/register": "7.27.1", "@saithodev/semantic-release-backmerge": "4.0.1", "@semantic-release/changelog": "6.0.3", "@semantic-release/commit-analyzer": "13.0.1", diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index 1fbab72636..a32c9f2d9c 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -1,6 +1,6 @@ { "spec_dir": "spec", - "spec_files": ["**/*.[sS]pec.js"], - "helpers": ["helper.js"], + "spec_files": ["**/*.[sS]pec.[jt]s"], + "helpers": ["support/tsRegister.js", "helper.js"], "random": true } diff --git a/spec/support/tsRegister.js b/spec/support/tsRegister.js new file mode 100644 index 0000000000..11b80b14a0 --- /dev/null +++ b/spec/support/tsRegister.js @@ -0,0 +1,13 @@ +'use strict'; +// Loaded by Jasmine BEFORE helper.js so .ts helpers/specs can be required at +// runtime. Babel auto-resolves spec/.babelrc (which already declares +// @babel/preset-typescript), so we do not declare presets here. +// +// Scope is intentionally narrow: only `.ts` files are intercepted, so the ~3900 +// existing `.js` specs keep running natively (no behavior change, no extra cost). +// `.js` requires (helpers, lib/) continue to resolve through Node directly. +require('@babel/register')({ + extensions: ['.ts'], + only: [/[\\/]spec[\\/]/], + cache: true, +}); From 4eb0a14d79c5712ab7d6fcf57462c6bbb26b2c4b Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 29 May 2026 23:15:34 +1000 Subject: [PATCH 02/10] test: Add REST test helpers and spec migration plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces spec/helpers/*.ts — a typed REST client over fetch (request, client, headers, config, errors, globals) so specs can talk to Parse Server through its public HTTP API without going through the Parse JS SDK. Also lands spec/spec_migration.md, the plan that drives the upcoming REST-first migration of the spec suite (towards #8787). --- spec/helpers/client.ts | 115 +++++++++++++++ spec/helpers/config.ts | 11 ++ spec/helpers/errors.ts | 7 + spec/helpers/globals.d.ts | 18 +++ spec/helpers/headers.ts | 30 ++++ spec/helpers/request.ts | 103 +++++++++++++ spec/spec_migration.md | 295 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 579 insertions(+) create mode 100644 spec/helpers/client.ts create mode 100644 spec/helpers/config.ts create mode 100644 spec/helpers/errors.ts create mode 100644 spec/helpers/globals.d.ts create mode 100644 spec/helpers/headers.ts create mode 100644 spec/helpers/request.ts create mode 100644 spec/spec_migration.md diff --git a/spec/helpers/client.ts b/spec/helpers/client.ts new file mode 100644 index 0000000000..16bfacec8e --- /dev/null +++ b/spec/helpers/client.ts @@ -0,0 +1,115 @@ +import { restRequest } from './request'; +import { AuthOptions } from './headers'; + +// Core object CRUD + query over the REST API. Defaults to REST API key auth; +// pass an explicit AuthOptions (e.g. { masterKey: true }) to override. +const DEFAULT_AUTH: AuthOptions = { restAPIKey: true }; + +export interface CreateResult { + objectId: string; + createdAt: string; +} + +export interface UpdateResult { + updatedAt: string; +} + +export interface FindParams { + where?: Record; + limit?: number; + skip?: number; + order?: string; + keys?: string; + include?: string; +} + +/** POST /classes/:className — create one object. */ +export async function createObject( + className: string, + data: Record, + auth: AuthOptions = DEFAULT_AUTH +): Promise { + const res = await restRequest({ + method: 'POST', + path: `classes/${className}`, + body: data, + auth, + }); + return res.data as CreateResult; +} + +/** Create several objects sequentially (deterministic), mirroring saveAll. */ +export async function createObjects( + className: string, + objects: Array>, + auth: AuthOptions = DEFAULT_AUTH +): Promise { + const results: CreateResult[] = []; + for (const data of objects) { + results.push(await createObject(className, data, auth)); + } + return results; +} + +/** GET /classes/:className/:objectId — fetch one object. */ +export async function getObject( + className: string, + objectId: string, + auth: AuthOptions = DEFAULT_AUTH +): Promise { + const res = await restRequest({ + method: 'GET', + path: `classes/${className}/${objectId}`, + auth, + }); + return res.data as T; +} + +/** PUT /classes/:className/:objectId — update one object. */ +export async function updateObject( + className: string, + objectId: string, + data: Record, + auth: AuthOptions = DEFAULT_AUTH +): Promise { + const res = await restRequest({ + method: 'PUT', + path: `classes/${className}/${objectId}`, + body: data, + auth, + }); + return res.data as UpdateResult; +} + +/** + * Query objects. Uses POST with `_method: 'GET'` so complex `where` clauses are + * sent in the body rather than the URL. Returns the results array. + */ +export async function find( + className: string, + params: FindParams = {}, + auth: AuthOptions = DEFAULT_AUTH +): Promise { + const res = await restRequest<{ results: T[] }>({ + method: 'POST', + path: `classes/${className}`, + body: { ...params, _method: 'GET' }, + auth, + }); + return (res.data as { results: T[] }).results; +} + +/** Count matching objects (count=1, limit=0). Returns the count. */ +export async function count( + className: string, + params: FindParams = {}, + auth: AuthOptions = DEFAULT_AUTH +): Promise { + const res = await restRequest<{ count: number }>({ + method: 'POST', + path: `classes/${className}`, + body: { ...params, count: 1, limit: 0, _method: 'GET' }, + auth, + }); + return (res.data as { count: number }).count; +} diff --git a/spec/helpers/config.ts b/spec/helpers/config.ts new file mode 100644 index 0000000000..d2cf3ff671 --- /dev/null +++ b/spec/helpers/config.ts @@ -0,0 +1,11 @@ +// Default test-server connection details, mirroring spec/helper.js +// defaultConfiguration. Single source of truth for the REST test client so +// specs never inline URLs or keys. +export const TestConfig = { + serverURL: 'http://localhost:8378/1', + appId: 'test', + masterKey: 'test', + restAPIKey: 'rest', + clientKey: 'client', + javascriptKey: 'test', +} as const; diff --git a/spec/helpers/errors.ts b/spec/helpers/errors.ts new file mode 100644 index 0000000000..284970774d --- /dev/null +++ b/spec/helpers/errors.ts @@ -0,0 +1,7 @@ +// Parse error codes used by the REST specs. Mirrors Parse.Error so specs can +// assert on response `code` values without importing the Parse JS SDK. +export const ParseError = { + INTERNAL_SERVER_ERROR: 1, + INVALID_JSON: 107, + INCORRECT_TYPE: 111, +} as const; diff --git a/spec/helpers/globals.d.ts b/spec/helpers/globals.d.ts new file mode 100644 index 0000000000..b86cbfa940 --- /dev/null +++ b/spec/helpers/globals.d.ts @@ -0,0 +1,18 @@ +// Ambient declarations for the Jasmine test globals defined in spec/helper.js. +// These let .ts specs reference the helpers without per-file `declare` clutter. +// (Dormant until a spec-scoped tsconfig is wired up in a later phase, but it is +// the canonical home for these contracts and has no runtime effect.) + +type SpecBody = (done?: () => void) => void | Promise; +type SpecFn = (name: string, body: SpecBody, timeout?: number) => void; + +declare function reconfigureServer(config?: Record): Promise; + +/** Assign a UUID to a test; disables it if the UUID is in the exclusion list. */ +declare function it_id(id: string): (spec: SpecFn) => SpecFn; + +/** Run a test on every database except the listed ones. */ +declare function it_exclude_dbs(dbs: string[]): SpecFn; + +/** Run a test only on the given database. */ +declare function it_only_db(db: string): SpecFn; diff --git a/spec/helpers/headers.ts b/spec/helpers/headers.ts new file mode 100644 index 0000000000..df824fd10d --- /dev/null +++ b/spec/helpers/headers.ts @@ -0,0 +1,30 @@ +import { TestConfig } from './config'; + +export type ParseHeaders = Record; + +export interface AuthOptions { + masterKey?: boolean; + maintenanceKey?: boolean; + restAPIKey?: boolean; + clientKey?: boolean; + javascriptKey?: boolean; + sessionToken?: string; + /** Default true; sets Content-Type: application/json so nested bodies survive. */ + json?: boolean; +} + +/** + * Build the X-Parse-* headers for a REST request from a set of auth options. + * Always includes the application id; other keys are opt-in. + */ +export function buildHeaders(auth: AuthOptions = {}): ParseHeaders { + const headers: ParseHeaders = { 'X-Parse-Application-Id': TestConfig.appId }; + if (auth.json !== false) headers['Content-Type'] = 'application/json'; + if (auth.masterKey) headers['X-Parse-Master-Key'] = TestConfig.masterKey; + if (auth.maintenanceKey) headers['X-Parse-Maintenance-Key'] = TestConfig.masterKey; + if (auth.restAPIKey) headers['X-Parse-REST-API-Key'] = TestConfig.restAPIKey; + if (auth.clientKey) headers['X-Parse-Client-Key'] = TestConfig.clientKey; + if (auth.javascriptKey) headers['X-Parse-JavaScript-Key'] = TestConfig.javascriptKey; + if (auth.sessionToken) headers['X-Parse-Session-Token'] = auth.sessionToken; + return headers; +} diff --git a/spec/helpers/request.ts b/spec/helpers/request.ts new file mode 100644 index 0000000000..267b636ce7 --- /dev/null +++ b/spec/helpers/request.ts @@ -0,0 +1,103 @@ +import { TestConfig } from './config'; +import { buildHeaders, AuthOptions, ParseHeaders } from './headers'; + +// Uses the global fetch (Node 18+). Reading `global.fetch` dynamically keeps the +// client compatible with spec/helper.js's mockFetch, which intercepts external +// URLs and passes our own server requests through to the real fetch. + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +export interface ParseResponse { + status: number; + headers: Record; + /** Parsed JSON body, or undefined when the response is empty / not JSON. */ + data: T | undefined; + text: string; +} + +export interface ParseErrorBody { + code: number; + error: string; +} + +export interface RestRequest { + method: HttpMethod; + /** Path under the server, e.g. 'events/MyEvent' (leading slash optional). */ + path: string; + body?: unknown; + auth?: AuthOptions; + /** Explicit header overrides; merged on top of the auth-derived headers. */ + headers?: ParseHeaders; +} + +/** Error thrown for non-2xx responses; carries the parsed body and response. */ +export interface ParseRequestError extends Error { + status: number; + data: ParseErrorBody | undefined; + response: ParseResponse; +} + +function url(path: string): string { + return `${TestConfig.serverURL}/${path.replace(/^\//, '')}`; +} + +/** + * Make a REST request to the test server via fetch. Resolves with the typed + * response on 2xx/3xx; rejects with a ParseRequestError on status < 200 || >= 400 + * (fetch itself only rejects on network errors, so we normalise that here). + */ +export async function restRequest(req: RestRequest): Promise> { + const headers = { ...buildHeaders(req.auth), ...(req.headers || {}) }; + const init: RequestInit = { method: req.method, headers }; + if (req.body !== undefined) { + init.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body); + } + + const res = await fetch(url(req.path), init); + const text = await res.text(); + let data: T | undefined; + try { + data = text ? JSON.parse(text) : undefined; + } catch { + data = undefined; + } + + const response: ParseResponse = { + status: res.status, + headers: Object.fromEntries(res.headers.entries()), + data, + text, + }; + + if (res.status < 200 || res.status >= 400) { + const error = new Error( + `Parse REST request failed with status ${res.status}` + ) as ParseRequestError; + error.status = res.status; + error.data = data as ParseErrorBody | undefined; + error.response = response; + throw error; + } + + return response; +} + +/** + * Assert that a request fails as a Parse error. Returns the parsed { code, error } + * body so callers can make further assertions. Fails the spec if it succeeds. + */ +export async function expectParseError( + promise: Promise, + code?: number +): Promise { + try { + await promise; + } catch (e: any) { + const body: ParseErrorBody = e && e.data ? e.data : e; + if (code !== undefined) { + expect(body.code).toBe(code); + } + return body; + } + throw new Error('Expected request to fail with a Parse error, but it succeeded'); +} diff --git a/spec/spec_migration.md b/spec/spec_migration.md new file mode 100644 index 0000000000..6cec25fd4f --- /dev/null +++ b/spec/spec_migration.md @@ -0,0 +1,295 @@ +# Test Suite Migration Plan: REST-First, Modular, TypeScript + +**Status:** Proposed +**Date:** 2026-05-20 +**Scope:** `spec/` (≈135 spec files, ≈100k lines) + +--- + +## 1. Goals + +1. **REST-first.** Tests exercise Parse Server through its public HTTP API, not the + Parse JS SDK. The SDK is a *separate product*; coupling the server's test suite to + it hides REST regressions, pins us to SDK behaviour/versions, and means SDK bugs + masquerade as server bugs. +2. **Modular & reorganised.** Replace the flat directory of giant files with a + domain-based tree of focused files (target ≤ ~500 lines each). +3. **Higher code quality.** Eliminate the `done()` callback style, arbitrary + `setTimeout` timing, missing cleanup, dead/skipped tests, and per-file boilerplate + duplication. +4. **TypeScript.** The new shared test helpers (and, over time, the specs) are written + in TypeScript with real types, so the test client is self-documenting and + editor-checked. + +## 2. Non-Goals + +- Rewriting the suite in a single "big bang" — migration is **incremental and in-place**. +- Changing the test runner (we stay on **Jasmine** + `mongodb-runner`). +- Changing what is tested (coverage must be preserved or improved, never silently dropped). +- Eliminating WebSockets — **LiveQuery** is a WebSocket protocol with no REST equivalent + and is handled with a dedicated WS test client (see §5.3). + +--- + +## 3. Current State (baseline) + +Measured at the time of writing — re-measure before starting to confirm. + +| Signal | Value | +|---|---| +| Spec files | ~135 (`spec/**/*.spec.js`) | +| Files using `done()` | 78 | +| Total `done()` usages | ~2,090 | +| `setTimeout` instances | ~74 | +| `xit` / `fit` (disabled/focused) tests | ~29 | +| Files with **no** `afterEach`/`afterAll` | ~80 | +| Files mixing SDK **and** raw `request()` | 42 | +| Largest files | `ParseGraphQLServer.spec.js` (12,264), `ParseQuery.spec.js` (5,568), `CloudCode.spec.js` (5,067), `ParseUser.spec.js` (4,618), `schemas.spec.js` (3,882) | + +**Runner facts that constrain the plan:** + +- `spec/support/jasmine.json`: `spec_dir: "spec"`, `spec_files: ["**/*.[sS]pec.js"]`, + `helpers: ["helper.js"]`, `random: true`. +- Specs run as **plain `.js` under Node** — there is **no runtime transpile hook today**. + `babel.config.js` already includes `@babel/preset-typescript`, but it is only used for + the `src/ → lib/` build, not for running specs. +- `spec/helper.js` (677 lines) provides globals: the `Parse` SDK instance, + `reconfigureServer`, `createTestUser`, `TestObject`/`Item`/`Container`, fetch mocks, + and DB-skip macros (`it_exclude_dbs`, `it_only_db`, `describe_only_db`, version gates). +- `lib/request.js` is the low-level HTTP client already used by 52 specs. + +--- + +## 4. Decisions (confirmed) + +| Decision | Choice | +|---|---| +| Roll-out | **Incremental, in-place** — convert files where they live, reorganise directories as part of each migration. | +| Server access | **New typed REST test client** wrapping `lib/request.js`. | +| SDK | **Eliminate the Parse JS SDK** from all HTTP-level tests. Retain only a thin WS client for LiveQuery (not the SDK's LiveQuery client). | +| Quality | **Full cleanup bundled into each file's migration** — async/await, no `setTimeout` timing, cleanup hooks, monolith splitting, eslint tightening, `xit`/`fit` triage. | +| Language | **TypeScript** for helpers/clients (and incrementally for specs). | +| Layout | **Domain-based** directory tree. | + +--- + +## 5. Target Architecture + +### 5.1 TypeScript in specs — the enabling change + +Add a single runtime transpile hook so Jasmine can load `.ts` helpers and specs: + +- New file `spec/support/tsRegister.js` (plain JS — it is the bootstrap): + ```js + require('@babel/register')({ + extensions: ['.ts', '.js'], + only: [/[\\/]spec[\\/]/], // never transpile node_modules / lib + cache: true, + }); + ``` +- `spec/support/jasmine.json`: + - add `tsRegister.js` as the **first** helper (must run before `helper.js`), + - widen `spec_files` to `["**/*.[sS]pec.[jt]s"]` so `.ts` specs are discovered, + - keep `random: true`. + +Babel config lives in `.babelrc` (repo root) and `spec/.babelrc`; the latter already +declares `@babel/preset-typescript`, and Babel auto-resolves it for any file under +`spec/`. So the hook needs **no** inline presets and **no** Babel config change — type +*stripping* is already covered. Type-*checking* (not just stripping) runs separately via a +`tsc --noEmit` lint step over `spec/` (see §8, Phase 0). + +> Rationale: this keeps the runner unchanged, requires no precompile step, and lets +> `.js` and `.ts` specs coexist during the long incremental migration. + +### 5.2 The REST test client (`spec/helpers/`) + +A small, typed layer over the native `fetch` API (Node 18+). **No Parse SDK and no +parse-server internals** — true black-box REST. Each function returns typed parsed +responses and rejects with a typed error (carrying Parse `code` + `error`) on non-2xx, +since `fetch` only rejects on network failures. Reads the global `fetch` dynamically so it +stays compatible with `helper.js`'s `mockFetch` (which passes our server URLs through). + +``` +spec/helpers/ + request.ts # typed fetch wrapper (status/headers/data) + expectParseError + headers.ts # header builders: appId, restKey, clientKey, masterKey, + # sessionToken, masterKey-via-X-Parse-Master-Key, etc. + client.ts # ParseRestClient: createObject/getObject/updateObject/ + # deleteObject/find/aggregate/batch/runFunction/... + users.ts # signUp/logIn/logOut/me/requestPasswordReset over REST + schema.ts # schema GET/POST/PUT/DELETE helpers + files.ts # file upload/get/delete over REST + fixtures.ts # factories: makeUser(), makeObjects(n), makeRole() — REST-based + reconfigure.ts # reconfigureServer extracted & typed + wsClient.ts # LiveQuery WebSocket test client (see 5.3) + index.ts # global Jasmine setup (the slimmed successor to helper.js) +``` + +Design constraints (per repo CLAUDE.md / SOLID): +- One responsibility per module; the client is composed from small focused pieces. +- Public functions accept an explicit, typed options object — no hidden globals. +- Errors are explicit: helpers expose `expectParseError(promise, code)` rather than + swallowing failures. +- No magic strings: app keys, URLs, default credentials come from a single typed + `config.ts` constants module. + +**Example shape (illustrative):** +```ts +export interface ParseObjectResponse { objectId: string; createdAt: string; } + +export async function createObject( + className: string, + data: Record, + auth: AuthHeaders = masterKey(), +): Promise { /* wraps request() POST /classes/:className */ } +``` + +### 5.3 LiveQuery / WebSocket client + +LiveQuery has no REST surface. Provide `spec/helpers/wsClient.ts` — a minimal typed +WebSocket client that speaks the LiveQuery protocol (connect / subscribe / unsubscribe +and event assertions via awaited promises, **not** `setTimeout`). LiveQuery specs use +this instead of the SDK's `Parse.LiveQueryClient`. + +### 5.4 Directory taxonomy (target) + +``` +spec/ + support/ # jasmine.json, tsRegister.js, reporters, adapter mocks + helpers/ # typed clients & fixtures (§5.2) + rest/ + objects/ # CRUD, batch, pointers, relations, data types + query/ # split of ParseQuery.spec.js by concern (filtering, ordering, + # pagination, includes, geo, aggregation, regex/security) + users/ # signup, login, sessions, password reset, auth data + files/ + schema/ + cloud/ # Cloud Code: beforeSave/afterSave/.../functions/jobs/validators + auth/ # auth adapters (OAuth, LDAP, custom, V2) + graphql/ # split of ParseGraphQLServer.spec.js (12k) by type/operation + livequery/ # WebSocket-based, uses wsClient.ts + server/ # config, CLI, middleware, security, deprecation, idempotency + adapters/ # storage / cache / push / email adapter unit tests +``` + +Monolith splits are by **concern within the domain**, e.g. +`ParseQuery.spec.js` → `rest/query/{filtering,ordering,pagination,includes,geo,regex}.spec.ts`. + +--- + +## 6. Per-File Migration Checklist (the quality bar) + +Every file migrated must satisfy **all** of the following before its old version is deleted: + +- [ ] Moved to its domain directory (§5.4) and renamed `.spec.ts`. +- [ ] **No Parse JS SDK** import (`parse/node`) — all server interaction via REST client + (or `wsClient` for LiveQuery). SDK-specific assertions re-expressed as REST/header + assertions. +- [ ] Every `it`/`beforeEach`/`afterEach` is `async` + `await`; **zero `done()`**. +- [ ] **No `setTimeout`-based timing.** Replace with awaited conditions / polling helper + / WS event promises. (Genuine "wait for TTL to elapse" cases use a named, documented + `advanceClock`/`waitFor` helper, not a bare magic number.) +- [ ] `afterEach` cleanup where the test creates server-side state not covered by the + global DB wipe (hooks, config, spies, schema). +- [ ] No shared mutable module-level state leaking across `it` blocks. +- [ ] Magic numbers named/explained or sourced from `config.ts`. +- [ ] Setup boilerplate replaced by `fixtures.ts` factories. +- [ ] `xit`/`fit` triaged: each is either fixed & enabled, or deleted with a one-line + rationale in the PR (no silent skips carried over). Tracked in a migration log. +- [ ] File ≤ ~500 lines (split if larger); `describe` blocks reflect concerns, not just + hook types. +- [ ] Passes the tightened spec eslint config and `tsc --noEmit`. + +--- + +## 7. Conventions & Standards (new) + +- **eslint for specs** (`spec/eslint.config.js`): re-enable `no-unused-vars`, + `require-atomic-updates`; add `no-restricted-imports` banning `parse/node` outside + `spec/helpers/wsClient.ts`-style exceptions; keep the existing `no-restricted-syntax` + rules. Add `@typescript-eslint` rules consistent with `src/` (no `any`). +- **Naming:** `domain/concern.spec.ts`; `describe(' via REST', ...)`. +- **One assertion concept per test**, descriptive titles, no `equal()`-style helper + aliases — use Jasmine matchers directly. +- **Determinism:** no order dependence (suite already runs `random: true`); no real time + waits. + +--- + +## 8. Phasing & Sequencing + +Each phase is independently shippable and reviewable. + +**Phase 0 — Infrastructure (no test behaviour change).** +- Add `@babel/register` hook + `tsRegister.js`; widen `spec_files` glob. +- Add `tsc --noEmit` lint over `spec/` and wire into `ci:check`. +- Land tightened spec eslint config in **warn** mode first. +- Acceptance: existing `.js` specs still pass unchanged; a trivial throwaway `.ts` spec runs. + +**Phase 1 — Helpers & clients.** +- Build `spec/helpers/*` (REST client, headers, fixtures, reconfigure, config, wsClient). +- Port the global `helper.js` setup into typed `spec/helpers/index.ts` (keep `helper.js` + as a thin shim re-exporting it until all specs move). +- Unit-test the REST client itself against a running server. +- Acceptance: clients have their own green specs; old suite untouched. + +**Phase 2 — Pilot domain: `rest/objects/`.** +- Migrate object CRUD/batch specs end-to-end as the reference implementation. +- Establish the review template and the migration log. +- Acceptance: pilot files meet the §6 checklist; reviewers sign off on the pattern. + +**Phase 3 — Domain-by-domain migration** (ordered by value/risk): +1. `rest/objects`, `rest/query` (largest behaviour surface) +2. `rest/users`, `auth` +3. `cloud` +4. `rest/schema`, `rest/files`, `server` +5. `graphql` (split the 12k-line monolith) +6. `livequery` (WS client) +7. `adapters` (mostly unit; least SDK coupling) + +One domain per PR (or per-file PRs within a domain for the big ones). Old file deleted in +the same PR that lands its replacement, so coverage never forks. + +**Phase 4 — Enforcement & cleanup.** +- Flip spec eslint + `no-restricted-imports` (ban `parse/node`) to **error**. +- Remove the `helper.js` shim; delete dead support files. +- Update CONTRIBUTING/test docs to describe the REST-first pattern. +- Acceptance: zero `parse/node` imports outside the sanctioned WS exception; zero `done()`; + CI green. + +--- + +## 9. Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| Coverage silently dropped during port | nyc coverage diff per PR; migration log lists every deleted/skipped test with reason; old file deleted only in the same PR as its replacement. | +| Long-lived `.js`/`.ts` coexistence causes confusion | Phase 0 makes both run; lint ban on `parse/node` flips to error only in Phase 4; clear "migrated domains" tracker. | +| `@babel/register` slows test startup | `cache: true` + `only: [/spec\//]`; measure startup before/after in Phase 0. | +| LiveQuery WS client under-tested | Build & self-test `wsClient.ts` in Phase 1 before any LiveQuery spec depends on it. | +| Hidden SDK behaviour (e.g. client-side validation) had no REST equivalent | Audit during port; where the SDK was the actual subject (e.g. `ClientSDK.spec.js` = `X-Parse-Client-Version` parsing), re-express as explicit header tests. | +| Flaky tests masked by current auto-retry reporter | Removing `setTimeout` timing should reduce flakiness; keep `CurrentSpecReporter` retry during migration, then review its flaky-list afterwards. | +| Scope creep into `src/` refactors | Strictly out of scope; behaviour changes to `src/` are separate PRs. | + +--- + +## 10. Success Criteria + +- 0 `parse/node` imports in `spec/` outside the sanctioned WS helper. +- 0 `done()` callbacks; 0 timing-based `setTimeout` in specs. +- No spec file > ~500 lines; monoliths split by concern. +- All `xit`/`fit` resolved (fixed or removed with rationale). +- Spec eslint + `tsc --noEmit` enforced in CI. +- Coverage ≥ pre-migration baseline. + +--- + +## 11. Open Questions / To Confirm Before Phase 3 + +- Per-file PRs vs per-domain PRs for the largest domains (`query`, `graphql`) — pick based + on review bandwidth. +- Whether to convert specs to `.ts` immediately on migration or land as `.js`-on-REST first + and TS-ify in a fast follow (recommended: `.ts` immediately, since the file is already + being rewritten). +- Final disposition of the auto-retry flaky reporter after migration. +``` From f3c93d2f287e22f22075022edfbf86fcd2629596 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 29 May 2026 23:15:34 +1000 Subject: [PATCH 03/10] test: Migrate Analytics.spec.js to REST Replaces spec/Analytics.spec.js (Parse SDK + done() callbacks) with spec/server/analytics.spec.ts (REST client + async/await), plus spec/helpers/analytics.ts. No SDK import, no done(), no setTimeout. First spec to land under the new REST-first layout. --- spec/Analytics.spec.js | 69 ----------------------------------- spec/helpers/analytics.ts | 26 +++++++++++++ spec/server/analytics.spec.ts | 33 +++++++++++++++++ 3 files changed, 59 insertions(+), 69 deletions(-) delete mode 100644 spec/Analytics.spec.js create mode 100644 spec/helpers/analytics.ts create mode 100644 spec/server/analytics.spec.ts diff --git a/spec/Analytics.spec.js b/spec/Analytics.spec.js deleted file mode 100644 index 049a2795c8..0000000000 --- a/spec/Analytics.spec.js +++ /dev/null @@ -1,69 +0,0 @@ -const analyticsAdapter = { - appOpened: function () {}, - trackEvent: function () {}, -}; - -describe('AnalyticsController', () => { - it('should track a simple event', done => { - spyOn(analyticsAdapter, 'trackEvent').and.callThrough(); - reconfigureServer({ - analyticsAdapter, - }) - .then(() => { - return Parse.Analytics.track('MyEvent', { - key: 'value', - count: '0', - }); - }) - .then( - () => { - expect(analyticsAdapter.trackEvent).toHaveBeenCalled(); - const lastCall = analyticsAdapter.trackEvent.calls.first(); - const args = lastCall.args; - expect(args[0]).toEqual('MyEvent'); - expect(args[1]).toEqual({ - dimensions: { - key: 'value', - count: '0', - }, - }); - done(); - }, - err => { - fail(JSON.stringify(err)); - done(); - } - ); - }); - - it('should track a app opened event', done => { - spyOn(analyticsAdapter, 'appOpened').and.callThrough(); - reconfigureServer({ - analyticsAdapter, - }) - .then(() => { - return Parse.Analytics.track('AppOpened', { - key: 'value', - count: '0', - }); - }) - .then( - () => { - expect(analyticsAdapter.appOpened).toHaveBeenCalled(); - const lastCall = analyticsAdapter.appOpened.calls.first(); - const args = lastCall.args; - expect(args[0]).toEqual({ - dimensions: { - key: 'value', - count: '0', - }, - }); - done(); - }, - err => { - fail(JSON.stringify(err)); - done(); - } - ); - }); -}); diff --git a/spec/helpers/analytics.ts b/spec/helpers/analytics.ts new file mode 100644 index 0000000000..68817e8031 --- /dev/null +++ b/spec/helpers/analytics.ts @@ -0,0 +1,26 @@ +import { restRequest, ParseResponse } from './request'; +import { AuthOptions } from './headers'; + +export type Dimensions = Record; + +/** POST /events/:eventName with a { dimensions } body. Defaults to REST API key auth. */ +export function track( + eventName: string, + dimensions: Dimensions, + auth: AuthOptions = { restAPIKey: true } +): Promise { + return restRequest({ + method: 'POST', + path: `events/${encodeURIComponent(eventName)}`, + body: { dimensions }, + auth, + }); +} + +/** POST /events/AppOpened with a { dimensions } body (reserved event route). */ +export function appOpened( + dimensions: Dimensions, + auth: AuthOptions = { restAPIKey: true } +): Promise { + return track('AppOpened', dimensions, auth); +} diff --git a/spec/server/analytics.spec.ts b/spec/server/analytics.spec.ts new file mode 100644 index 0000000000..9aec466365 --- /dev/null +++ b/spec/server/analytics.spec.ts @@ -0,0 +1,33 @@ +import { track, appOpened } from '../helpers/analytics'; + +declare const reconfigureServer: (config?: Record) => Promise; + +describe('AnalyticsController', () => { + const analyticsAdapter = { + appOpened: function () {}, + trackEvent: function () {}, + }; + + it('should track a simple event', async () => { + const trackSpy = spyOn(analyticsAdapter, 'trackEvent').and.callThrough(); + await reconfigureServer({ analyticsAdapter }); + + await track('MyEvent', { key: 'value', count: '0' }); + + expect(trackSpy).toHaveBeenCalled(); + const args = trackSpy.calls.first().args; + expect(args[0]).toEqual('MyEvent'); + expect(args[1]).toEqual({ dimensions: { key: 'value', count: '0' } }); + }); + + it('should track a app opened event', async () => { + const appOpenedSpy = spyOn(analyticsAdapter, 'appOpened').and.callThrough(); + await reconfigureServer({ analyticsAdapter }); + + await appOpened({ key: 'value', count: '0' }); + + expect(appOpenedSpy).toHaveBeenCalled(); + const args = appOpenedSpy.calls.first().args; + expect(args[0]).toEqual({ dimensions: { key: 'value', count: '0' } }); + }); +}); From a1e353e2ed8c27759da1e9228530bf83ac591dd3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 29 May 2026 23:23:47 +1000 Subject: [PATCH 04/10] test: Load .ts specs via require so Node 24 native type-stripping doesn't break them CI runs Node 24, which natively strips TypeScript and loads .ts as ESM, bypassing the @babel/register CJS hook. ESM needs explicit extensions, so extensionless relative imports failed. Force Jasmine's require loader (engages the hook on Node 20), add explicit .ts extensions, and mark type-only imports with 'import type' so the suite loads identically on Node 20/22/24. --- spec/helpers/analytics.ts | 5 +++-- spec/helpers/client.ts | 4 ++-- spec/helpers/headers.ts | 2 +- spec/helpers/request.ts | 5 +++-- spec/server/analytics.spec.ts | 2 +- spec/support/jasmine.json | 1 + 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/spec/helpers/analytics.ts b/spec/helpers/analytics.ts index 68817e8031..5bac815499 100644 --- a/spec/helpers/analytics.ts +++ b/spec/helpers/analytics.ts @@ -1,5 +1,6 @@ -import { restRequest, ParseResponse } from './request'; -import { AuthOptions } from './headers'; +import { restRequest } from './request.ts'; +import type { ParseResponse } from './request.ts'; +import type { AuthOptions } from './headers.ts'; export type Dimensions = Record; diff --git a/spec/helpers/client.ts b/spec/helpers/client.ts index 16bfacec8e..97905fec11 100644 --- a/spec/helpers/client.ts +++ b/spec/helpers/client.ts @@ -1,5 +1,5 @@ -import { restRequest } from './request'; -import { AuthOptions } from './headers'; +import { restRequest } from './request.ts'; +import type { AuthOptions } from './headers.ts'; // Core object CRUD + query over the REST API. Defaults to REST API key auth; // pass an explicit AuthOptions (e.g. { masterKey: true }) to override. diff --git a/spec/helpers/headers.ts b/spec/helpers/headers.ts index df824fd10d..2b7150c858 100644 --- a/spec/helpers/headers.ts +++ b/spec/helpers/headers.ts @@ -1,4 +1,4 @@ -import { TestConfig } from './config'; +import { TestConfig } from './config.ts'; export type ParseHeaders = Record; diff --git a/spec/helpers/request.ts b/spec/helpers/request.ts index 267b636ce7..36ceef8f59 100644 --- a/spec/helpers/request.ts +++ b/spec/helpers/request.ts @@ -1,5 +1,6 @@ -import { TestConfig } from './config'; -import { buildHeaders, AuthOptions, ParseHeaders } from './headers'; +import { TestConfig } from './config.ts'; +import { buildHeaders } from './headers.ts'; +import type { AuthOptions, ParseHeaders } from './headers.ts'; // Uses the global fetch (Node 18+). Reading `global.fetch` dynamically keeps the // client compatible with spec/helper.js's mockFetch, which intercepts external diff --git a/spec/server/analytics.spec.ts b/spec/server/analytics.spec.ts index 9aec466365..6276d6924b 100644 --- a/spec/server/analytics.spec.ts +++ b/spec/server/analytics.spec.ts @@ -1,4 +1,4 @@ -import { track, appOpened } from '../helpers/analytics'; +import { track, appOpened } from '../helpers/analytics.ts'; declare const reconfigureServer: (config?: Record) => Promise; diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index a32c9f2d9c..1c520b09a8 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -2,5 +2,6 @@ "spec_dir": "spec", "spec_files": ["**/*.[sS]pec.[jt]s"], "helpers": ["support/tsRegister.js", "helper.js"], + "jsLoader": "require", "random": true } From 63620c729f69f60bab547a46ba05f0796ca8706b Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 29 May 2026 23:35:27 +1000 Subject: [PATCH 05/10] test: Address CodeRabbit feedback on REST helpers and analytics spec Guard undefined response data in find/count helpers and fix grammar in the analytics spec description. --- spec/helpers/client.ts | 4 ++-- spec/server/analytics.spec.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/helpers/client.ts b/spec/helpers/client.ts index 97905fec11..5a7b94472a 100644 --- a/spec/helpers/client.ts +++ b/spec/helpers/client.ts @@ -96,7 +96,7 @@ export async function find( body: { ...params, _method: 'GET' }, auth, }); - return (res.data as { results: T[] }).results; + return (res.data as { results: T[] } | undefined)?.results ?? []; } /** Count matching objects (count=1, limit=0). Returns the count. */ @@ -111,5 +111,5 @@ export async function count( body: { ...params, count: 1, limit: 0, _method: 'GET' }, auth, }); - return (res.data as { count: number }).count; + return (res.data as { count: number } | undefined)?.count ?? 0; } diff --git a/spec/server/analytics.spec.ts b/spec/server/analytics.spec.ts index 6276d6924b..36bf96b6d4 100644 --- a/spec/server/analytics.spec.ts +++ b/spec/server/analytics.spec.ts @@ -20,7 +20,7 @@ describe('AnalyticsController', () => { expect(args[1]).toEqual({ dimensions: { key: 'value', count: '0' } }); }); - it('should track a app opened event', async () => { + it('should track an app opened event', async () => { const appOpenedSpy = spyOn(analyticsAdapter, 'appOpened').and.callThrough(); await reconfigureServer({ analyticsAdapter }); From b524ba369c92f3e5faf6c48f56d3412d5c275b7a Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 30 May 2026 00:17:29 +1000 Subject: [PATCH 06/10] test: Harden REST helpers per CodeRabbit review Use a dedicated maintenance-key value in buildHeaders, assert the Parse-error shape in expectParseError, drop the redundant local reconfigureServer decl (it lives in globals.d.ts), and fix the REST-client description in the plan. --- spec/helpers/config.ts | 1 + spec/helpers/headers.ts | 2 +- spec/helpers/request.ts | 5 +++++ spec/server/analytics.spec.ts | 2 -- spec/spec_migration.md | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/spec/helpers/config.ts b/spec/helpers/config.ts index d2cf3ff671..8c7f90ffbf 100644 --- a/spec/helpers/config.ts +++ b/spec/helpers/config.ts @@ -5,6 +5,7 @@ export const TestConfig = { serverURL: 'http://localhost:8378/1', appId: 'test', masterKey: 'test', + maintenanceKey: 'testing', restAPIKey: 'rest', clientKey: 'client', javascriptKey: 'test', diff --git a/spec/helpers/headers.ts b/spec/helpers/headers.ts index 2b7150c858..31235041c5 100644 --- a/spec/helpers/headers.ts +++ b/spec/helpers/headers.ts @@ -21,7 +21,7 @@ export function buildHeaders(auth: AuthOptions = {}): ParseHeaders { const headers: ParseHeaders = { 'X-Parse-Application-Id': TestConfig.appId }; if (auth.json !== false) headers['Content-Type'] = 'application/json'; if (auth.masterKey) headers['X-Parse-Master-Key'] = TestConfig.masterKey; - if (auth.maintenanceKey) headers['X-Parse-Maintenance-Key'] = TestConfig.masterKey; + if (auth.maintenanceKey) headers['X-Parse-Maintenance-Key'] = TestConfig.maintenanceKey; if (auth.restAPIKey) headers['X-Parse-REST-API-Key'] = TestConfig.restAPIKey; if (auth.clientKey) headers['X-Parse-Client-Key'] = TestConfig.clientKey; if (auth.javascriptKey) headers['X-Parse-JavaScript-Key'] = TestConfig.javascriptKey; diff --git a/spec/helpers/request.ts b/spec/helpers/request.ts index 36ceef8f59..4639136377 100644 --- a/spec/helpers/request.ts +++ b/spec/helpers/request.ts @@ -95,6 +95,11 @@ export async function expectParseError( await promise; } catch (e: any) { const body: ParseErrorBody = e && e.data ? e.data : e; + if (typeof body?.code !== 'number' || typeof body?.error !== 'string') { + throw new Error( + `Expected a Parse error ({ code, error }), but got: ${JSON.stringify(body)}` + ); + } if (code !== undefined) { expect(body.code).toBe(code); } diff --git a/spec/server/analytics.spec.ts b/spec/server/analytics.spec.ts index 36bf96b6d4..5f9297de3b 100644 --- a/spec/server/analytics.spec.ts +++ b/spec/server/analytics.spec.ts @@ -1,7 +1,5 @@ import { track, appOpened } from '../helpers/analytics.ts'; -declare const reconfigureServer: (config?: Record) => Promise; - describe('AnalyticsController', () => { const analyticsAdapter = { appOpened: function () {}, diff --git a/spec/spec_migration.md b/spec/spec_migration.md index 6cec25fd4f..604a923958 100644 --- a/spec/spec_migration.md +++ b/spec/spec_migration.md @@ -65,7 +65,7 @@ Measured at the time of writing — re-measure before starting to confirm. | Decision | Choice | |---|---| | Roll-out | **Incremental, in-place** — convert files where they live, reorganise directories as part of each migration. | -| Server access | **New typed REST test client** wrapping `lib/request.js`. | +| Server access | **New typed REST test client** — a small, typed layer over the native `fetch` API (Node 18+). | | SDK | **Eliminate the Parse JS SDK** from all HTTP-level tests. Retain only a thin WS client for LiveQuery (not the SDK's LiveQuery client). | | Quality | **Full cleanup bundled into each file's migration** — async/await, no `setTimeout` timing, cleanup hooks, monolith splitting, eslint tightening, `xit`/`fit` triage. | | Language | **TypeScript** for helpers/clients (and incrementally for specs). | From 93beae5905f5480f629601feb4632d04f4195f72 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 30 May 2026 00:18:50 +1000 Subject: [PATCH 07/10] test: Relax expectParseError to check only the numeric code Internal-server-error bodies use { code, message } rather than { code, error }, so a numeric code is the reliable signal that a rejection is a Parse error. --- spec/helpers/request.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/helpers/request.ts b/spec/helpers/request.ts index 4639136377..bbfb00e6a2 100644 --- a/spec/helpers/request.ts +++ b/spec/helpers/request.ts @@ -95,9 +95,9 @@ export async function expectParseError( await promise; } catch (e: any) { const body: ParseErrorBody = e && e.data ? e.data : e; - if (typeof body?.code !== 'number' || typeof body?.error !== 'string') { + if (typeof body?.code !== 'number') { throw new Error( - `Expected a Parse error ({ code, error }), but got: ${JSON.stringify(body)}` + `Expected a Parse error (numeric code), but got: ${e instanceof Error ? e.stack : JSON.stringify(body)}` ); } if (code !== undefined) { From 82c4409cb3cacc2267e095b32892bad36225d14b Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 29 May 2026 23:51:57 +1000 Subject: [PATCH 08/10] test: Migrate GeoPoint specs to the REST client Port spec/ParseGeoPoint.spec.js to REST-based specs under spec/rest/objects (lifecycle, queries, withinPolygon) and add a geo query helper, removing the Parse JS SDK from these tests. --- spec/ParseGeoPoint.spec.js | 789 --------------------- spec/helpers/geo.ts | 53 ++ spec/rest/objects/geopoint-polygon.spec.ts | 125 ++++ spec/rest/objects/geopoint-query.spec.ts | 189 +++++ spec/rest/objects/geopoint.spec.ts | 76 ++ 5 files changed, 443 insertions(+), 789 deletions(-) delete mode 100644 spec/ParseGeoPoint.spec.js create mode 100644 spec/helpers/geo.ts create mode 100644 spec/rest/objects/geopoint-polygon.spec.ts create mode 100644 spec/rest/objects/geopoint-query.spec.ts create mode 100644 spec/rest/objects/geopoint.spec.ts diff --git a/spec/ParseGeoPoint.spec.js b/spec/ParseGeoPoint.spec.js deleted file mode 100644 index f154f0048e..0000000000 --- a/spec/ParseGeoPoint.spec.js +++ /dev/null @@ -1,789 +0,0 @@ -// This is a port of the test suite: -// hungry/js/test/parse_geo_point_test.js - -const request = require('../lib/request'); -const TestObject = Parse.Object.extend('TestObject'); - -describe('Parse.GeoPoint testing', () => { - it('geo point roundtrip', async () => { - const point = new Parse.GeoPoint(44.0, -11.0); - const obj = new TestObject(); - obj.set('location', point); - obj.set('name', 'Ferndale'); - await obj.save(); - const result = await new Parse.Query(TestObject).get(obj.id); - const pointAgain = result.get('location'); - ok(pointAgain); - equal(pointAgain.latitude, 44.0); - equal(pointAgain.longitude, -11.0); - }); - - it('update geopoint', done => { - const oldPoint = new Parse.GeoPoint(44.0, -11.0); - const newPoint = new Parse.GeoPoint(24.0, 19.0); - const obj = new TestObject(); - obj.set('location', oldPoint); - obj - .save() - .then(() => { - obj.set('location', newPoint); - return obj.save(); - }) - .then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }) - .then(result => { - const point = result.get('location'); - equal(point.latitude, newPoint.latitude); - equal(point.longitude, newPoint.longitude); - done(); - }); - }); - - it('has the correct __type field in the json response', async done => { - const point = new Parse.GeoPoint(44.0, -11.0); - const obj = new TestObject(); - obj.set('location', point); - obj.set('name', 'Zhoul'); - await obj.save(); - request({ - url: 'http://localhost:8378/1/classes/TestObject/' + obj.id, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - }).then(response => { - equal(response.data.location.__type, 'GeoPoint'); - done(); - }); - }); - - it('creating geo point exception two fields', done => { - const point = new Parse.GeoPoint(20, 20); - const obj = new TestObject(); - obj.set('locationOne', point); - obj.set('locationTwo', point); - obj.save().then( - () => { - fail('expected error'); - }, - err => { - equal(err.code, Parse.Error.INCORRECT_TYPE); - done(); - } - ); - }); - - // TODO: This should also have support in postgres, or higher level database agnostic support. - it_exclude_dbs(['postgres'])('updating geo point exception two fields', async done => { - const point = new Parse.GeoPoint(20, 20); - const obj = new TestObject(); - obj.set('locationOne', point); - await obj.save(); - obj.set('locationTwo', point); - obj.save().then( - () => { - fail('expected error'); - }, - err => { - equal(err.code, Parse.Error.INCORRECT_TYPE); - done(); - } - ); - }); - - it_id('bbd9e2f6-7f61-458f-98f2-4a563586cd8d')(it)('geo line', async done => { - const line = []; - for (let i = 0; i < 10; ++i) { - const obj = new TestObject(); - const point = new Parse.GeoPoint(i * 4.0 - 12.0, i * 3.2 - 11.0); - obj.set('location', point); - obj.set('construct', 'line'); - obj.set('seq', i); - line.push(obj); - } - await Parse.Object.saveAll(line); - const query = new Parse.Query(TestObject); - const point = new Parse.GeoPoint(24, 19); - query.equalTo('construct', 'line'); - query.withinMiles('location', point, 10000); - const results = await query.find(); - equal(results.length, 10); - equal(results[0].get('seq'), 9); - equal(results[3].get('seq'), 6); - done(); - }); - - it('geo max distance large', done => { - const objects = []; - [0, 1, 2].map(function (i) { - const obj = new TestObject(); - const point = new Parse.GeoPoint(0.0, i * 45.0); - obj.set('location', point); - obj.set('index', i); - objects.push(obj); - }); - Parse.Object.saveAll(objects) - .then(() => { - const query = new Parse.Query(TestObject); - const point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14); - return query.find(); - }) - .then( - results => { - equal(results.length, 3); - done(); - }, - err => { - fail("Couldn't query GeoPoint"); - jfail(err); - } - ); - }); - - it_id('e1e86b38-b8a4-4109-8330-a324fe628e0c')(it)('geo max distance medium', async () => { - const objects = []; - [0, 1, 2].map(function (i) { - const obj = new TestObject(); - const point = new Parse.GeoPoint(0.0, i * 45.0); - obj.set('location', point); - obj.set('index', i); - objects.push(obj); - }); - await Parse.Object.saveAll(objects); - const query = new Parse.Query(TestObject); - const point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14 * 0.5); - const results = await query.find(); - equal(results.length, 2); - equal(results[0].get('index'), 0); - equal(results[1].get('index'), 1); - }); - - it('geo max distance small', async () => { - const objects = []; - [0, 1, 2].map(function (i) { - const obj = new TestObject(); - const point = new Parse.GeoPoint(0.0, i * 45.0); - obj.set('location', point); - obj.set('index', i); - objects.push(obj); - }); - await Parse.Object.saveAll(objects); - const query = new Parse.Query(TestObject); - const point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14 * 0.25); - const results = await query.find(); - equal(results.length, 1); - equal(results[0].get('index'), 0); - }); - - const makeSomeGeoPoints = function () { - const sacramento = new TestObject(); - sacramento.set('location', new Parse.GeoPoint(38.52, -121.5)); - sacramento.set('name', 'Sacramento'); - - const honolulu = new TestObject(); - honolulu.set('location', new Parse.GeoPoint(21.35, -157.93)); - honolulu.set('name', 'Honolulu'); - - const sf = new TestObject(); - sf.set('location', new Parse.GeoPoint(37.75, -122.68)); - sf.set('name', 'San Francisco'); - - return Parse.Object.saveAll([sacramento, sf, honolulu]); - }; - - it('geo max distance in km everywhere', async done => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - // Honolulu is 4300 km away from SFO on a sphere ;) - query.withinKilometers('location', sfo, 4800.0); - const results = await query.find(); - equal(results.length, 3); - done(); - }); - - it_id('05f1a454-56b1-4f2e-908e-408a9222cbae')(it)('geo max distance in km california', async () => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 3700.0); - const results = await query.find(); - equal(results.length, 2); - equal(results[0].get('name'), 'San Francisco'); - equal(results[1].get('name'), 'Sacramento'); - }); - - it('geo max distance in km bay area', async () => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 100.0); - const results = await query.find(); - equal(results.length, 1); - equal(results[0].get('name'), 'San Francisco'); - }); - - it('geo max distance in km mid peninsula', async () => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 10.0); - const results = await query.find(); - equal(results.length, 0); - }); - - it('geo max distance in miles everywhere', async () => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 2600.0); - const results = await query.find(); - equal(results.length, 3); - }); - - it_id('9ee376ad-dd6c-4c17-ad28-c7899a4411f1')(it)('geo max distance in miles california', async () => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 2200.0); - const results = await query.find(); - equal(results.length, 2); - equal(results[0].get('name'), 'San Francisco'); - equal(results[1].get('name'), 'Sacramento'); - }); - - it('geo max distance in miles bay area', async () => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 62.0); - const results = await query.find(); - equal(results.length, 1); - equal(results[0].get('name'), 'San Francisco'); - }); - - it('geo max distance in miles mid peninsula', async () => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 10.0); - const results = await query.find(); - equal(results.length, 0); - }); - - it_id('9e35a89e-bc2c-4ec5-b25a-8d1890a55233')(it)('returns nearest location', async () => { - await makeSomeGeoPoints(); - const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - const query = new Parse.Query(TestObject); - query.near('location', sfo); - const results = await query.find(); - equal(results[0].get('name'), 'San Francisco'); - equal(results[1].get('name'), 'Sacramento'); - }); - - it_id('6df434b0-142d-4302-bbc6-a6ec5a9d9c68')(it)('works with geobox queries', done => { - const inbound = new Parse.GeoPoint(1.5, 1.5); - const onbound = new Parse.GeoPoint(10, 10); - const outbound = new Parse.GeoPoint(20, 20); - const obj1 = new Parse.Object('TestObject', { location: inbound }); - const obj2 = new Parse.Object('TestObject', { location: onbound }); - const obj3 = new Parse.Object('TestObject', { location: outbound }); - Parse.Object.saveAll([obj1, obj2, obj3]) - .then(() => { - const sw = new Parse.GeoPoint(0, 0); - const ne = new Parse.GeoPoint(10, 10); - const query = new Parse.Query(TestObject); - query.withinGeoBox('location', sw, ne); - return query.find(); - }) - .then(results => { - equal(results.length, 2); - done(); - }); - }); - - it('supports a sub-object with a geo point', async () => { - const point = new Parse.GeoPoint(44.0, -11.0); - const obj = new TestObject(); - obj.set('subobject', { location: point }); - await obj.save(); - const query = new Parse.Query(TestObject); - const results = await query.find(); - equal(results.length, 1); - const pointAgain = results[0].get('subobject')['location']; - ok(pointAgain); - equal(pointAgain.latitude, 44.0); - equal(pointAgain.longitude, -11.0); - }); - - it('supports array of geo points', async () => { - const point1 = new Parse.GeoPoint(44.0, -11.0); - const point2 = new Parse.GeoPoint(22.0, -55.0); - const obj = new TestObject(); - obj.set('locations', [point1, point2]); - await obj.save(); - const query = new Parse.Query(TestObject); - const results = await query.find(); - equal(results.length, 1); - const locations = results[0].get('locations'); - expect(locations.length).toEqual(2); - expect(locations[0]).toEqual(point1); - expect(locations[1]).toEqual(point2); - }); - - it('equalTo geopoint', done => { - const point = new Parse.GeoPoint(44.0, -11.0); - const obj = new TestObject(); - obj.set('location', point); - obj - .save() - .then(() => { - const query = new Parse.Query(TestObject); - query.equalTo('location', point); - return query.find(); - }) - .then(results => { - equal(results.length, 1); - const loc = results[0].get('location'); - equal(loc.latitude, point.latitude); - equal(loc.longitude, point.longitude); - done(); - }); - }); - - it_id('d9fbc5c6-f767-47d6-bb44-3858eb9df15a')(it)('supports withinPolygon open path', done => { - const inbound = new Parse.GeoPoint(1.5, 1.5); - const onbound = new Parse.GeoPoint(10, 10); - const outbound = new Parse.GeoPoint(20, 20); - const obj1 = new Parse.Object('Polygon', { location: inbound }); - const obj2 = new Parse.Object('Polygon', { location: onbound }); - const obj3 = new Parse.Object('Polygon', { location: outbound }); - Parse.Object.saveAll([obj1, obj2, obj3]) - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 10 }, - { __type: 'GeoPoint', latitude: 10, longitude: 10 }, - { __type: 'GeoPoint', latitude: 10, longitude: 0 }, - ], - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(2); - done(); - }, done.fail); - }); - - it_id('3ec537bd-839a-4c93-a48b-b4a249820074')(it)('supports withinPolygon closed path', done => { - const inbound = new Parse.GeoPoint(1.5, 1.5); - const onbound = new Parse.GeoPoint(10, 10); - const outbound = new Parse.GeoPoint(20, 20); - const obj1 = new Parse.Object('Polygon', { location: inbound }); - const obj2 = new Parse.Object('Polygon', { location: onbound }); - const obj3 = new Parse.Object('Polygon', { location: outbound }); - Parse.Object.saveAll([obj1, obj2, obj3]) - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 10 }, - { __type: 'GeoPoint', latitude: 10, longitude: 10 }, - { __type: 'GeoPoint', latitude: 10, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - ], - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(2); - done(); - }, done.fail); - }); - - it_id('0a248e11-3598-480a-9ab5-8a0b259258e4')(it)('supports withinPolygon Polygon object', done => { - const inbound = new Parse.GeoPoint(1.5, 1.5); - const onbound = new Parse.GeoPoint(10, 10); - const outbound = new Parse.GeoPoint(20, 20); - const obj1 = new Parse.Object('Polygon', { location: inbound }); - const obj2 = new Parse.Object('Polygon', { location: onbound }); - const obj3 = new Parse.Object('Polygon', { location: outbound }); - const polygon = { - __type: 'Polygon', - coordinates: [ - [0, 0], - [10, 0], - [10, 10], - [0, 10], - [0, 0], - ], - }; - Parse.Object.saveAll([obj1, obj2, obj3]) - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: polygon, - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(2); - done(); - }, done.fail); - }); - - it('invalid Polygon object withinPolygon', done => { - const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', { location: point }); - const polygon = { - __type: 'Polygon', - coordinates: [ - [0, 0], - [10, 0], - ], - }; - obj - .save() - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: polygon, - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); - }); - - it('out of bounds Polygon object withinPolygon', done => { - const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', { location: point }); - const polygon = { - __type: 'Polygon', - coordinates: [ - [0, 0], - [181, 0], - [0, 10], - ], - }; - obj - .save() - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: polygon, - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(1); - done(); - }); - }); - - it('invalid input withinPolygon', done => { - const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', { location: point }); - obj - .save() - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: 1234, - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); - }); - - it('invalid geoPoint withinPolygon', done => { - const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', { location: point }); - obj - .save() - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [{}], - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); - }); - - it('invalid latitude withinPolygon', done => { - const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', { location: point }); - obj - .save() - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - { __type: 'GeoPoint', latitude: 181, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - ], - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(1); - done(); - }); - }); - - it('invalid longitude withinPolygon', done => { - const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', { location: point }); - obj - .save() - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 181 }, - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - ], - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(1); - done(); - }); - }); - - it('minimum 3 points withinPolygon', done => { - const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', { location: point }); - obj - .save() - .then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [], - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/Polygon', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(107); - done(); - }); - }); - - it('withinKilometers supports count', async () => { - const inside = new Parse.GeoPoint(10, 10); - const outside = new Parse.GeoPoint(20, 20); - - const obj1 = new Parse.Object('TestObject', { location: inside }); - const obj2 = new Parse.Object('TestObject', { location: outside }); - - await Parse.Object.saveAll([obj1, obj2]); - - const q = new Parse.Query(TestObject).withinKilometers('location', inside, 5); - const count = await q.count(); - - equal(count, 1); - }); - - it_id('0b073d31-0d41-41e7-bd60-f636ffb759dc')(it)('withinKilometers complex supports count', async () => { - const inside = new Parse.GeoPoint(10, 10); - const middle = new Parse.GeoPoint(20, 20); - const outside = new Parse.GeoPoint(30, 30); - const obj1 = new Parse.Object('TestObject', { location: inside }); - const obj2 = new Parse.Object('TestObject', { location: middle }); - const obj3 = new Parse.Object('TestObject', { location: outside }); - - await Parse.Object.saveAll([obj1, obj2, obj3]); - - const q1 = new Parse.Query(TestObject).withinKilometers('location', inside, 5); - const q2 = new Parse.Query(TestObject).withinKilometers('location', middle, 5); - const query = Parse.Query.or(q1, q2); - const count = await query.count(); - - equal(count, 2); - }); - - it_id('26c9a13d-3d71-452e-a91c-9a4589be021c')(it)('fails to fetch geopoints that are specifically not at (0,0)', async () => { - const tmp = new TestObject({ - location: new Parse.GeoPoint({ latitude: 0, longitude: 0 }), - }); - const tmp2 = new TestObject({ - location: new Parse.GeoPoint({ - latitude: 49.2577142, - longitude: -123.1941149, - }), - }); - await Parse.Object.saveAll([tmp, tmp2]); - const query = new Parse.Query(TestObject); - query.notEqualTo('location', new Parse.GeoPoint({ latitude: 0, longitude: 0 })); - const results = await query.find(); - expect(results.length).toEqual(1); - }); -}); diff --git a/spec/helpers/geo.ts b/spec/helpers/geo.ts new file mode 100644 index 0000000000..8038fa2a67 --- /dev/null +++ b/spec/helpers/geo.ts @@ -0,0 +1,53 @@ +// Builders for GeoPoint literals and geo query constraints, expressed as the +// raw REST `where` JSON that Parse Server expects. These mirror the wire format +// produced by the Parse JS SDK's Parse.Query geo methods, so query semantics +// (and result ordering) are identical. + +export interface GeoPointLiteral { + __type: 'GeoPoint'; + latitude: number; + longitude: number; +} + +// Earth radii used by the Parse SDK to convert distances to radians. +const EARTH_RADIUS_MILES = 3958.8; +const EARTH_RADIUS_KM = 6371.0; + +export function geoPoint(latitude: number, longitude: number): GeoPointLiteral { + return { __type: 'GeoPoint', latitude, longitude }; +} + +/** Sorted proximity search with no max distance ($nearSphere). */ +export function near(point: GeoPointLiteral) { + return { $nearSphere: point }; +} + +/** Sorted proximity search within a max distance in radians. */ +export function withinRadians(point: GeoPointLiteral, maxDistance: number) { + return { $nearSphere: point, $maxDistance: maxDistance }; +} + +/** Sorted proximity search within a max distance in miles. */ +export function withinMiles(point: GeoPointLiteral, miles: number) { + return withinRadians(point, miles / EARTH_RADIUS_MILES); +} + +/** Sorted proximity search within a max distance in kilometers. */ +export function withinKilometers(point: GeoPointLiteral, kilometers: number) { + return withinRadians(point, kilometers / EARTH_RADIUS_KM); +} + +/** Rectangular search between a south-west and north-east corner. */ +export function withinGeoBox(southwest: GeoPointLiteral, northeast: GeoPointLiteral) { + return { $within: { $box: [southwest, northeast] } }; +} + +/** Search within a polygon, given either a list of points or a Polygon object. */ +export function withinPolygon(polygon: GeoPointLiteral[] | unknown) { + return { $geoWithin: { $polygon: polygon } }; +} + +/** Inequality constraint ($ne). */ +export function notEqualTo(value: unknown) { + return { $ne: value }; +} diff --git a/spec/rest/objects/geopoint-polygon.spec.ts b/spec/rest/objects/geopoint-polygon.spec.ts new file mode 100644 index 0000000000..d1551879c5 --- /dev/null +++ b/spec/rest/objects/geopoint-polygon.spec.ts @@ -0,0 +1,125 @@ +// withinPolygon ($geoWithin / $polygon) queries and their validation errors. +// Ported from spec/ParseGeoPoint.spec.js. +import { createObject, createObjects, find } from '../../helpers/client.ts'; +import { geoPoint, withinPolygon } from '../../helpers/geo.ts'; +import { expectParseError } from '../../helpers/request.ts'; +import { ParseError } from '../../helpers/errors.ts'; + +function seedPolygonPoints() { + return createObjects('Polygon', [ + { location: geoPoint(1.5, 1.5) }, // inbound + { location: geoPoint(10, 10) }, // onbound + { location: geoPoint(20, 20) }, // outbound + ]); +} + +describe('withinPolygon REST', () => { + it_id('d9fbc5c6-f767-47d6-bb44-3858eb9df15a')(it)('supports withinPolygon open path', async () => { + await seedPolygonPoints(); + const results = await find('Polygon', { + where: { + location: withinPolygon([ + geoPoint(0, 0), + geoPoint(0, 10), + geoPoint(10, 10), + geoPoint(10, 0), + ]), + }, + }); + expect(results.length).toEqual(2); + }); + + it_id('3ec537bd-839a-4c93-a48b-b4a249820074')(it)('supports withinPolygon closed path', async () => { + await seedPolygonPoints(); + const results = await find('Polygon', { + where: { + location: withinPolygon([ + geoPoint(0, 0), + geoPoint(0, 10), + geoPoint(10, 10), + geoPoint(10, 0), + geoPoint(0, 0), + ]), + }, + }); + expect(results.length).toEqual(2); + }); + + it_id('0a248e11-3598-480a-9ab5-8a0b259258e4')(it)('supports withinPolygon Polygon object', async () => { + await seedPolygonPoints(); + const polygon = { + __type: 'Polygon', + coordinates: [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0], + ], + }; + const results = await find('Polygon', { where: { location: withinPolygon(polygon) } }); + expect(results.length).toEqual(2); + }); + + it('invalid Polygon object withinPolygon', async () => { + await createObject('Polygon', { location: geoPoint(1.5, 1.5) }); + const polygon = { __type: 'Polygon', coordinates: [[0, 0], [10, 0]] }; + await expectParseError( + find('Polygon', { where: { location: withinPolygon(polygon) } }), + ParseError.INVALID_JSON + ); + }); + + it('out of bounds Polygon object withinPolygon', async () => { + await createObject('Polygon', { location: geoPoint(1.5, 1.5) }); + const polygon = { __type: 'Polygon', coordinates: [[0, 0], [181, 0], [0, 10]] }; + await expectParseError( + find('Polygon', { where: { location: withinPolygon(polygon) } }), + ParseError.INTERNAL_SERVER_ERROR + ); + }); + + it('invalid input withinPolygon', async () => { + await createObject('Polygon', { location: geoPoint(1.5, 1.5) }); + await expectParseError( + find('Polygon', { where: { location: withinPolygon(1234) } }), + ParseError.INVALID_JSON + ); + }); + + it('invalid geoPoint withinPolygon', async () => { + await createObject('Polygon', { location: geoPoint(1.5, 1.5) }); + await expectParseError( + find('Polygon', { where: { location: withinPolygon([{}]) } }), + ParseError.INVALID_JSON + ); + }); + + it('invalid latitude withinPolygon', async () => { + await createObject('Polygon', { location: geoPoint(1.5, 1.5) }); + await expectParseError( + find('Polygon', { + where: { location: withinPolygon([geoPoint(0, 0), geoPoint(181, 0), geoPoint(0, 0)]) }, + }), + ParseError.INTERNAL_SERVER_ERROR + ); + }); + + it('invalid longitude withinPolygon', async () => { + await createObject('Polygon', { location: geoPoint(1.5, 1.5) }); + await expectParseError( + find('Polygon', { + where: { location: withinPolygon([geoPoint(0, 0), geoPoint(0, 181), geoPoint(0, 0)]) }, + }), + ParseError.INTERNAL_SERVER_ERROR + ); + }); + + it('minimum 3 points withinPolygon', async () => { + await createObject('Polygon', { location: geoPoint(1.5, 1.5) }); + await expectParseError( + find('Polygon', { where: { location: withinPolygon([]) } }), + ParseError.INVALID_JSON + ); + }); +}); diff --git a/spec/rest/objects/geopoint-query.spec.ts b/spec/rest/objects/geopoint-query.spec.ts new file mode 100644 index 0000000000..5f31be0946 --- /dev/null +++ b/spec/rest/objects/geopoint-query.spec.ts @@ -0,0 +1,189 @@ +// GeoPoint proximity / distance / equality queries over REST. +// Ported from spec/ParseGeoPoint.spec.js. +import { createObject, createObjects, find, count } from '../../helpers/client.ts'; +import { + geoPoint, + near, + withinRadians, + withinMiles, + withinKilometers, + withinGeoBox, + notEqualTo, +} from '../../helpers/geo.ts'; + +// Three well-known cities used across the distance assertions. +function makeSomeGeoPoints() { + return createObjects('TestObject', [ + { location: geoPoint(38.52, -121.5), name: 'Sacramento' }, + { location: geoPoint(37.75, -122.68), name: 'San Francisco' }, + { location: geoPoint(21.35, -157.93), name: 'Honolulu' }, + ]); +} + +const SFO = geoPoint(37.6189722, -122.3748889); + +describe('GeoPoint query REST', () => { + it_id('bbd9e2f6-7f61-458f-98f2-4a563586cd8d')(it)('geo line', async () => { + const objects = []; + for (let i = 0; i < 10; ++i) { + objects.push({ location: geoPoint(i * 4.0 - 12.0, i * 3.2 - 11.0), construct: 'line', seq: i }); + } + await createObjects('TestObject', objects); + const results = await find('TestObject', { + where: { construct: 'line', location: withinMiles(geoPoint(24, 19), 10000) }, + }); + expect(results.length).toEqual(10); + expect(results[0].seq).toEqual(9); + expect(results[3].seq).toEqual(6); + }); + + it('geo max distance large', async () => { + await createObjects('TestObject', [0, 1, 2].map(i => ({ location: geoPoint(0.0, i * 45.0), index: i }))); + const results = await find('TestObject', { + where: { location: withinRadians(geoPoint(1.0, -1.0), 3.14) }, + }); + expect(results.length).toEqual(3); + }); + + it_id('e1e86b38-b8a4-4109-8330-a324fe628e0c')(it)('geo max distance medium', async () => { + await createObjects('TestObject', [0, 1, 2].map(i => ({ location: geoPoint(0.0, i * 45.0), index: i }))); + const results = await find('TestObject', { + where: { location: withinRadians(geoPoint(1.0, -1.0), 3.14 * 0.5) }, + }); + expect(results.length).toEqual(2); + expect(results[0].index).toEqual(0); + expect(results[1].index).toEqual(1); + }); + + it('geo max distance small', async () => { + await createObjects('TestObject', [0, 1, 2].map(i => ({ location: geoPoint(0.0, i * 45.0), index: i }))); + const results = await find('TestObject', { + where: { location: withinRadians(geoPoint(1.0, -1.0), 3.14 * 0.25) }, + }); + expect(results.length).toEqual(1); + expect(results[0].index).toEqual(0); + }); + + it('geo max distance in km everywhere', async () => { + await makeSomeGeoPoints(); + // Honolulu is ~4300 km from SFO on a sphere. + const results = await find('TestObject', { where: { location: withinKilometers(SFO, 4800.0) } }); + expect(results.length).toEqual(3); + }); + + it_id('05f1a454-56b1-4f2e-908e-408a9222cbae')(it)('geo max distance in km california', async () => { + await makeSomeGeoPoints(); + const results = await find('TestObject', { where: { location: withinKilometers(SFO, 3700.0) } }); + expect(results.length).toEqual(2); + expect(results[0].name).toEqual('San Francisco'); + expect(results[1].name).toEqual('Sacramento'); + }); + + it('geo max distance in km bay area', async () => { + await makeSomeGeoPoints(); + const results = await find('TestObject', { where: { location: withinKilometers(SFO, 100.0) } }); + expect(results.length).toEqual(1); + expect(results[0].name).toEqual('San Francisco'); + }); + + it('geo max distance in km mid peninsula', async () => { + await makeSomeGeoPoints(); + const results = await find('TestObject', { where: { location: withinKilometers(SFO, 10.0) } }); + expect(results.length).toEqual(0); + }); + + it('geo max distance in miles everywhere', async () => { + await makeSomeGeoPoints(); + const results = await find('TestObject', { where: { location: withinMiles(SFO, 2600.0) } }); + expect(results.length).toEqual(3); + }); + + it_id('9ee376ad-dd6c-4c17-ad28-c7899a4411f1')(it)('geo max distance in miles california', async () => { + await makeSomeGeoPoints(); + const results = await find('TestObject', { where: { location: withinMiles(SFO, 2200.0) } }); + expect(results.length).toEqual(2); + expect(results[0].name).toEqual('San Francisco'); + expect(results[1].name).toEqual('Sacramento'); + }); + + it('geo max distance in miles bay area', async () => { + await makeSomeGeoPoints(); + const results = await find('TestObject', { where: { location: withinMiles(SFO, 62.0) } }); + expect(results.length).toEqual(1); + expect(results[0].name).toEqual('San Francisco'); + }); + + it('geo max distance in miles mid peninsula', async () => { + await makeSomeGeoPoints(); + const results = await find('TestObject', { where: { location: withinMiles(SFO, 10.0) } }); + expect(results.length).toEqual(0); + }); + + it_id('9e35a89e-bc2c-4ec5-b25a-8d1890a55233')(it)('returns nearest location', async () => { + await makeSomeGeoPoints(); + const results = await find('TestObject', { where: { location: near(SFO) } }); + expect(results[0].name).toEqual('San Francisco'); + expect(results[1].name).toEqual('Sacramento'); + }); + + it_id('6df434b0-142d-4302-bbc6-a6ec5a9d9c68')(it)('works with geobox queries', async () => { + await createObjects('TestObject', [ + { location: geoPoint(1.5, 1.5) }, + { location: geoPoint(10, 10) }, + { location: geoPoint(20, 20) }, + ]); + const results = await find('TestObject', { + where: { location: withinGeoBox(geoPoint(0, 0), geoPoint(10, 10)) }, + }); + expect(results.length).toEqual(2); + }); + + it('equalTo geopoint', async () => { + const point = geoPoint(44.0, -11.0); + await createObject('TestObject', { location: point }); + const results = await find('TestObject', { where: { location: point } }); + expect(results.length).toEqual(1); + expect(results[0].location.latitude).toEqual(44.0); + expect(results[0].location.longitude).toEqual(-11.0); + }); + + it('withinKilometers supports count', async () => { + const inside = geoPoint(10, 10); + await createObjects('TestObject', [{ location: inside }, { location: geoPoint(20, 20) }]); + const total = await count('TestObject', { where: { location: withinKilometers(inside, 5) } }); + expect(total).toEqual(1); + }); + + it_id('0b073d31-0d41-41e7-bd60-f636ffb759dc')(it)('withinKilometers complex supports count', async () => { + const inside = geoPoint(10, 10); + const middle = geoPoint(20, 20); + await createObjects('TestObject', [ + { location: inside }, + { location: middle }, + { location: geoPoint(30, 30) }, + ]); + const total = await count('TestObject', { + where: { + $or: [ + { location: withinKilometers(inside, 5) }, + { location: withinKilometers(middle, 5) }, + ], + }, + }); + expect(total).toEqual(2); + }); + + it_id('26c9a13d-3d71-452e-a91c-9a4589be021c')(it)( + 'fails to fetch geopoints that are specifically not at (0,0)', + async () => { + await createObjects('TestObject', [ + { location: geoPoint(0, 0) }, + { location: geoPoint(49.2577142, -123.1941149) }, + ]); + const results = await find('TestObject', { + where: { location: notEqualTo(geoPoint(0, 0)) }, + }); + expect(results.length).toEqual(1); + } + ); +}); diff --git a/spec/rest/objects/geopoint.spec.ts b/spec/rest/objects/geopoint.spec.ts new file mode 100644 index 0000000000..9a3bf10a12 --- /dev/null +++ b/spec/rest/objects/geopoint.spec.ts @@ -0,0 +1,76 @@ +// GeoPoint object lifecycle over REST (save / get / update / type errors). +// Ported from spec/ParseGeoPoint.spec.js (originally hungry/js geo point tests). +import { createObject, getObject, updateObject, find } from '../../helpers/client.ts'; +import { geoPoint } from '../../helpers/geo.ts'; +import { expectParseError } from '../../helpers/request.ts'; +import { ParseError } from '../../helpers/errors.ts'; + +describe('GeoPoint REST', () => { + it('geo point roundtrip', async () => { + const created = await createObject('TestObject', { + location: geoPoint(44.0, -11.0), + name: 'Ferndale', + }); + const result = await getObject('TestObject', created.objectId); + expect(result.location).toBeTruthy(); + expect(result.location.latitude).toEqual(44.0); + expect(result.location.longitude).toEqual(-11.0); + }); + + it('update geopoint', async () => { + const created = await createObject('TestObject', { location: geoPoint(44.0, -11.0) }); + await updateObject('TestObject', created.objectId, { location: geoPoint(24.0, 19.0) }); + const result = await getObject('TestObject', created.objectId); + expect(result.location.latitude).toEqual(24.0); + expect(result.location.longitude).toEqual(19.0); + }); + + it('has the correct __type field in the json response', async () => { + const created = await createObject('TestObject', { + location: geoPoint(44.0, -11.0), + name: 'Zhoul', + }); + const result = await getObject('TestObject', created.objectId, { masterKey: true }); + expect(result.location.__type).toEqual('GeoPoint'); + }); + + it('creating geo point exception two fields', async () => { + const point = geoPoint(20, 20); + await expectParseError( + createObject('TestObject', { locationOne: point, locationTwo: point }), + ParseError.INCORRECT_TYPE + ); + }); + + // TODO: This should also have support in postgres, or higher level database agnostic support. + it_exclude_dbs(['postgres'])('updating geo point exception two fields', async () => { + const point = geoPoint(20, 20); + const created = await createObject('TestObject', { locationOne: point }); + await expectParseError( + updateObject('TestObject', created.objectId, { locationTwo: point }), + ParseError.INCORRECT_TYPE + ); + }); + + it('supports a sub-object with a geo point', async () => { + await createObject('TestObject', { subobject: { location: geoPoint(44.0, -11.0) } }); + const results = await find('TestObject'); + expect(results.length).toEqual(1); + const pointAgain = results[0].subobject.location; + expect(pointAgain).toBeTruthy(); + expect(pointAgain.latitude).toEqual(44.0); + expect(pointAgain.longitude).toEqual(-11.0); + }); + + it('supports array of geo points', async () => { + const point1 = geoPoint(44.0, -11.0); + const point2 = geoPoint(22.0, -55.0); + await createObject('TestObject', { locations: [point1, point2] }); + const results = await find('TestObject'); + expect(results.length).toEqual(1); + const locations = results[0].locations; + expect(locations.length).toEqual(2); + expect(locations[0]).toEqual(point1); + expect(locations[1]).toEqual(point2); + }); +}); From c9fa5013d82ed2334e3cd406439a5adb4072563d Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 30 May 2026 00:40:42 +1000 Subject: [PATCH 09/10] test: Address CodeRabbit review on GeoPoint specs - Drop the misleading GeoPointLiteral[] | unknown union on withinPolygon (negative-path specs deliberately pass invalid values, so the param is unknown) - Drop the unnecessary masterKey auth in the __type response test - Filter the sub-object and array specs on a sentinel tag instead of an unfiltered find, so they don't depend on global cleanup ordering - Clarify the seed-point comments (inside / on boundary / outside) --- spec/helpers/geo.ts | 8 ++++++-- spec/rest/objects/geopoint-polygon.spec.ts | 6 +++--- spec/rest/objects/geopoint.spec.ts | 13 ++++++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/spec/helpers/geo.ts b/spec/helpers/geo.ts index 8038fa2a67..0e320613dd 100644 --- a/spec/helpers/geo.ts +++ b/spec/helpers/geo.ts @@ -42,8 +42,12 @@ export function withinGeoBox(southwest: GeoPointLiteral, northeast: GeoPointLite return { $within: { $box: [southwest, northeast] } }; } -/** Search within a polygon, given either a list of points or a Polygon object. */ -export function withinPolygon(polygon: GeoPointLiteral[] | unknown) { +/** + * Search within a polygon, given a list of GeoPoints or a Polygon object. + * Typed as `unknown` because the negative-path specs deliberately pass invalid + * values (numbers, empty arrays, malformed points) to exercise server validation. + */ +export function withinPolygon(polygon: unknown) { return { $geoWithin: { $polygon: polygon } }; } diff --git a/spec/rest/objects/geopoint-polygon.spec.ts b/spec/rest/objects/geopoint-polygon.spec.ts index d1551879c5..238a507dea 100644 --- a/spec/rest/objects/geopoint-polygon.spec.ts +++ b/spec/rest/objects/geopoint-polygon.spec.ts @@ -7,9 +7,9 @@ import { ParseError } from '../../helpers/errors.ts'; function seedPolygonPoints() { return createObjects('Polygon', [ - { location: geoPoint(1.5, 1.5) }, // inbound - { location: geoPoint(10, 10) }, // onbound - { location: geoPoint(20, 20) }, // outbound + { location: geoPoint(1.5, 1.5) }, // inside + { location: geoPoint(10, 10) }, // on boundary + { location: geoPoint(20, 20) }, // outside ]); } diff --git a/spec/rest/objects/geopoint.spec.ts b/spec/rest/objects/geopoint.spec.ts index 9a3bf10a12..16c3a744ac 100644 --- a/spec/rest/objects/geopoint.spec.ts +++ b/spec/rest/objects/geopoint.spec.ts @@ -30,7 +30,7 @@ describe('GeoPoint REST', () => { location: geoPoint(44.0, -11.0), name: 'Zhoul', }); - const result = await getObject('TestObject', created.objectId, { masterKey: true }); + const result = await getObject('TestObject', created.objectId); expect(result.location.__type).toEqual('GeoPoint'); }); @@ -53,8 +53,11 @@ describe('GeoPoint REST', () => { }); it('supports a sub-object with a geo point', async () => { - await createObject('TestObject', { subobject: { location: geoPoint(44.0, -11.0) } }); - const results = await find('TestObject'); + await createObject('TestObject', { + subobject: { location: geoPoint(44.0, -11.0) }, + tag: 'subobject-geo', + }); + const results = await find('TestObject', { where: { tag: 'subobject-geo' } }); expect(results.length).toEqual(1); const pointAgain = results[0].subobject.location; expect(pointAgain).toBeTruthy(); @@ -65,8 +68,8 @@ describe('GeoPoint REST', () => { it('supports array of geo points', async () => { const point1 = geoPoint(44.0, -11.0); const point2 = geoPoint(22.0, -55.0); - await createObject('TestObject', { locations: [point1, point2] }); - const results = await find('TestObject'); + await createObject('TestObject', { locations: [point1, point2], tag: 'array-geo' }); + const results = await find('TestObject', { where: { tag: 'array-geo' } }); expect(results.length).toEqual(1); const locations = results[0].locations; expect(locations.length).toEqual(2); From 27fdf426df98f75d5f5f61dc5d44f42a132961d2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 30 May 2026 01:12:58 +1000 Subject: [PATCH 10/10] test: Migrate Polygon specs to the REST client Ports spec/ParsePolygon.spec.js to REST-based specs under spec/rest/objects/: - polygon.spec.ts: object lifecycle, equalTo, and validation - polygon-query.spec.ts: $geoIntersects point-in-polygon queries (#4608) - polygon-mongo.spec.ts: MongoDB storage format and 2d/2dsphere indexes Adds polygon/geoIntersects builders to spec/helpers/geo.ts and a describe_only_db ambient declaration. No Parse JS SDK; the storage spec uses the database adapter directly only where there is no REST surface. --- spec/ParsePolygon.spec.js | 544 ------------------------ spec/helpers/geo.ts | 14 + spec/helpers/globals.d.ts | 3 + spec/rest/objects/polygon-mongo.spec.ts | 87 ++++ spec/rest/objects/polygon-query.spec.ts | 65 +++ spec/rest/objects/polygon.spec.ts | 93 ++++ 6 files changed, 262 insertions(+), 544 deletions(-) delete mode 100644 spec/ParsePolygon.spec.js create mode 100644 spec/rest/objects/polygon-mongo.spec.ts create mode 100644 spec/rest/objects/polygon-query.spec.ts create mode 100644 spec/rest/objects/polygon.spec.ts diff --git a/spec/ParsePolygon.spec.js b/spec/ParsePolygon.spec.js deleted file mode 100644 index c2fc903206..0000000000 --- a/spec/ParsePolygon.spec.js +++ /dev/null @@ -1,544 +0,0 @@ -const TestObject = Parse.Object.extend('TestObject'); -const request = require('../lib/request'); -const TestUtils = require('../lib/TestUtils'); -const defaultHeaders = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Rest-API-Key': 'rest', - 'Content-Type': 'application/json', -}; - -describe('Parse.Polygon testing', () => { - it('polygon save open path', done => { - const coords = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - ]; - const closed = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - [0, 0], - ]; - const obj = new TestObject(); - obj.set('polygon', new Parse.Polygon(coords)); - return obj - .save() - .then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }) - .then(result => { - const polygon = result.get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, closed); - done(); - }, done.fail); - }); - - it('polygon save closed path', done => { - const coords = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - [0, 0], - ]; - const obj = new TestObject(); - obj.set('polygon', new Parse.Polygon(coords)); - return obj - .save() - .then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }) - .then(result => { - const polygon = result.get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, coords); - done(); - }, done.fail); - }); - - it_id('3019353b-d5b3-4e53-bcb1-716418328bdd')(it)('polygon equalTo (open/closed) path', done => { - const openPoints = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - ]; - const closedPoints = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - [0, 0], - ]; - const openPolygon = new Parse.Polygon(openPoints); - const closedPolygon = new Parse.Polygon(closedPoints); - const obj = new TestObject(); - obj.set('polygon', openPolygon); - return obj - .save() - .then(() => { - const query = new Parse.Query(TestObject); - query.equalTo('polygon', openPolygon); - return query.find(); - }) - .then(results => { - const polygon = results[0].get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, closedPoints); - const query = new Parse.Query(TestObject); - query.equalTo('polygon', closedPolygon); - return query.find(); - }) - .then(results => { - const polygon = results[0].get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, closedPoints); - done(); - }, done.fail); - }); - - it('polygon update', done => { - const oldCoords = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - ]; - const oldPolygon = new Parse.Polygon(oldCoords); - const newCoords = [ - [2, 2], - [2, 3], - [3, 3], - [3, 2], - ]; - const newPolygon = new Parse.Polygon(newCoords); - const obj = new TestObject(); - obj.set('polygon', oldPolygon); - return obj - .save() - .then(() => { - obj.set('polygon', newPolygon); - return obj.save(); - }) - .then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }) - .then(result => { - const polygon = result.get('polygon'); - newCoords.push(newCoords[0]); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, newCoords); - done(); - }, done.fail); - }); - - it('polygon invalid value', done => { - const coords = [ - ['foo', 'bar'], - [0, 1], - [1, 0], - [1, 1], - [0, 0], - ]; - const obj = new TestObject(); - obj.set('polygon', { __type: 'Polygon', coordinates: coords }); - return obj - .save() - .then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }) - .then(done.fail, () => done()); - }); - - it('polygon three points minimum', done => { - const coords = [[0, 0]]; - const obj = new TestObject(); - // use raw so we test the server validates properly - obj.set('polygon', { __type: 'Polygon', coordinates: coords }); - obj.save().then(done.fail, () => done()); - }); - - it('polygon three different points minimum', done => { - const coords = [ - [0, 0], - [0, 1], - [0, 0], - ]; - const obj = new TestObject(); - obj.set('polygon', new Parse.Polygon(coords)); - obj.save().then(done.fail, () => done()); - }); - - it('polygon counterclockwise', done => { - const coords = [ - [1, 1], - [0, 1], - [0, 0], - [1, 0], - ]; - const closed = [ - [1, 1], - [0, 1], - [0, 0], - [1, 0], - [1, 1], - ]; - const obj = new TestObject(); - obj.set('polygon', new Parse.Polygon(coords)); - obj - .save() - .then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }) - .then(result => { - const polygon = result.get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, closed); - done(); - }, done.fail); - }); - - describe('with location', () => { - if (process.env.PARSE_SERVER_TEST_DB !== 'postgres') { - beforeEach(async () => await TestUtils.destroyAllDataPermanently()); - } - - it('polygonContain query', done => { - const points1 = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - ]; - const points2 = [ - [0, 0], - [0, 2], - [2, 2], - [2, 0], - ]; - const points3 = [ - [10, 10], - [10, 15], - [15, 15], - [15, 10], - [10, 10], - ]; - const polygon1 = new Parse.Polygon(points1); - const polygon2 = new Parse.Polygon(points2); - const polygon3 = new Parse.Polygon(points3); - const obj1 = new TestObject({ boundary: polygon1 }); - const obj2 = new TestObject({ boundary: polygon2 }); - const obj3 = new TestObject({ boundary: polygon3 }); - Parse.Object.saveAll([obj1, obj2, obj3]) - .then(() => { - const where = { - boundary: { - $geoIntersects: { - $point: { __type: 'GeoPoint', latitude: 0.5, longitude: 0.5 }, - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(2); - done(); - }, done.fail); - }); - - it('polygonContain query no reverse input (Regression test for #4608)', done => { - const points1 = [ - [0.25, 0], - [0.25, 1.25], - [0.75, 1.25], - [0.75, 0], - ]; - const points2 = [ - [0, 0], - [0, 2], - [2, 2], - [2, 0], - ]; - const points3 = [ - [10, 10], - [10, 15], - [15, 15], - [15, 10], - [10, 10], - ]; - const polygon1 = new Parse.Polygon(points1); - const polygon2 = new Parse.Polygon(points2); - const polygon3 = new Parse.Polygon(points3); - const obj1 = new TestObject({ boundary: polygon1 }); - const obj2 = new TestObject({ boundary: polygon2 }); - const obj3 = new TestObject({ boundary: polygon3 }); - Parse.Object.saveAll([obj1, obj2, obj3]) - .then(() => { - const where = { - boundary: { - $geoIntersects: { - $point: { __type: 'GeoPoint', latitude: 0.5, longitude: 1.0 }, - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(2); - done(); - }, done.fail); - }); - - it('polygonContain query real data (Regression test for #4608)', done => { - const detroit = [ - [42.631655189280224, -83.78406753121705], - [42.633047793854814, -83.75333640366955], - [42.61625254348911, -83.75149921669944], - [42.61526926650296, -83.78161794858735], - [42.631655189280224, -83.78406753121705], - ]; - const polygon = new Parse.Polygon(detroit); - const obj = new TestObject({ boundary: polygon }); - obj - .save() - .then(() => { - const where = { - boundary: { - $geoIntersects: { - $point: { - __type: 'GeoPoint', - latitude: 42.624599, - longitude: -83.770162, - }, - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(1); - done(); - }, done.fail); - }); - - it('polygonContain invalid input', done => { - const points = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - ]; - const polygon = new Parse.Polygon(points); - const obj = new TestObject({ boundary: polygon }); - obj - .save() - .then(() => { - const where = { - boundary: { - $geoIntersects: { - $point: { __type: 'GeoPoint', latitude: 181, longitude: 181 }, - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - }, - }); - }) - .then(done.fail, () => done()); - }); - - it('polygonContain invalid geoPoint', done => { - const points = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - ]; - const polygon = new Parse.Polygon(points); - const obj = new TestObject({ boundary: polygon }); - obj - .save() - .then(() => { - const where = { - boundary: { - $geoIntersects: { - $point: [], - }, - }, - }; - return request({ - method: 'POST', - url: Parse.serverURL + '/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - }, - }); - }) - .then(done.fail, () => done()); - }); - }); -}); - -describe_only_db('mongo')('Parse.Polygon testing mongo', () => { - const Config = require('../lib/Config'); - let config; - beforeEach(async () => { - if (process.env.PARSE_SERVER_TEST_DB !== 'postgres') { - await TestUtils.destroyAllDataPermanently(); - } - config = Config.get('test'); - config.schemaCache.clear(); - }); - it('support 2d and 2dsphere', done => { - const coords = [ - [0, 0], - [0, 1], - [1, 1], - [1, 0], - [0, 0], - ]; - // testings against REST API, use raw formats - const polygon = { __type: 'Polygon', coordinates: coords }; - const location = { __type: 'GeoPoint', latitude: 10, longitude: 10 }; - const databaseAdapter = config.database.adapter; - return reconfigureServer({ - appId: 'test', - restAPIKey: 'rest', - publicServerURL: 'http://localhost:8378/1', - databaseAdapter, - }) - .then(() => { - return databaseAdapter.createIndex('TestObject', { location: '2d' }); - }) - .then(() => { - return databaseAdapter.createIndex('TestObject', { - polygon: '2dsphere', - }); - }) - .then(() => { - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { - _method: 'POST', - location, - polygon, - polygon2: polygon, - }, - headers: defaultHeaders, - }); - }) - .then(resp => { - return request({ - method: 'POST', - url: `http://localhost:8378/1/classes/TestObject/${resp.data.objectId}`, - body: { _method: 'GET' }, - headers: defaultHeaders, - }); - }) - .then(resp => { - equal(resp.data.location, location); - equal(resp.data.polygon, polygon); - equal(resp.data.polygon2, polygon); - return databaseAdapter.getIndexes('TestObject'); - }) - .then(indexes => { - equal(indexes.length, 4); - equal(indexes[0].key, { _id: 1 }); - equal(indexes[1].key, { location: '2d' }); - equal(indexes[2].key, { polygon: '2dsphere' }); - equal(indexes[3].key, { polygon2: '2dsphere' }); - done(); - }, done.fail); - }); - - it('polygon coordinates reverse input', done => { - const Config = require('../lib/Config'); - const config = Config.get('test'); - - // When stored the first point should be the last point - const input = [ - [12, 11], - [14, 13], - [16, 15], - [18, 17], - ]; - const output = [ - [ - [11, 12], - [13, 14], - [15, 16], - [17, 18], - [11, 12], - ], - ]; - const obj = new TestObject(); - obj.set('polygon', new Parse.Polygon(input)); - obj - .save() - .then(() => { - return config.database.adapter._rawFind('TestObject', { _id: obj.id }); - }) - .then(results => { - expect(results.length).toBe(1); - expect(results[0].polygon.coordinates).toEqual(output); - done(); - }); - }); - - it('polygon loop is not valid', done => { - const coords = [ - [0, 0], - [0, 1], - [1, 0], - [1, 1], - ]; - const obj = new TestObject(); - obj.set('polygon', new Parse.Polygon(coords)); - obj.save().then(done.fail, () => done()); - }); -}); diff --git a/spec/helpers/geo.ts b/spec/helpers/geo.ts index 0e320613dd..c04af1f418 100644 --- a/spec/helpers/geo.ts +++ b/spec/helpers/geo.ts @@ -9,6 +9,11 @@ export interface GeoPointLiteral { longitude: number; } +export interface PolygonLiteral { + __type: 'Polygon'; + coordinates: number[][]; +} + // Earth radii used by the Parse SDK to convert distances to radians. const EARTH_RADIUS_MILES = 3958.8; const EARTH_RADIUS_KM = 6371.0; @@ -17,6 +22,10 @@ export function geoPoint(latitude: number, longitude: number): GeoPointLiteral { return { __type: 'GeoPoint', latitude, longitude }; } +export function polygon(coordinates: number[][]): PolygonLiteral { + return { __type: 'Polygon', coordinates }; +} + /** Sorted proximity search with no max distance ($nearSphere). */ export function near(point: GeoPointLiteral) { return { $nearSphere: point }; @@ -51,6 +60,11 @@ export function withinPolygon(polygon: unknown) { return { $geoWithin: { $polygon: polygon } }; } +/** Point-in-polygon search: matches stored polygons containing the point ($geoIntersects). */ +export function geoIntersects(point: unknown) { + return { $geoIntersects: { $point: point } }; +} + /** Inequality constraint ($ne). */ export function notEqualTo(value: unknown) { return { $ne: value }; diff --git a/spec/helpers/globals.d.ts b/spec/helpers/globals.d.ts index b86cbfa940..d03bcc0dc3 100644 --- a/spec/helpers/globals.d.ts +++ b/spec/helpers/globals.d.ts @@ -16,3 +16,6 @@ declare function it_exclude_dbs(dbs: string[]): SpecFn; /** Run a test only on the given database. */ declare function it_only_db(db: string): SpecFn; + +/** Run a describe block only on the given database. */ +declare function describe_only_db(db: string): (name: string, body: () => void) => void; diff --git a/spec/rest/objects/polygon-mongo.spec.ts b/spec/rest/objects/polygon-mongo.spec.ts new file mode 100644 index 0000000000..2140886547 --- /dev/null +++ b/spec/rest/objects/polygon-mongo.spec.ts @@ -0,0 +1,87 @@ +// Polygon storage internals on MongoDB: GeoJSON coordinate order and 2d/2dsphere +// index support. Ported from spec/ParsePolygon.spec.js. Objects are created over +// REST, but the assertions reach into the database adapter (raw stored documents +// and index metadata) because those have no REST surface. +import { createObject, getObject } from '../../helpers/client.ts'; +import { geoPoint, polygon } from '../../helpers/geo.ts'; +import { expectParseError } from '../../helpers/request.ts'; + +const Config = require('../../../lib/Config'); + +describe_only_db('mongo')('Polygon storage (MongoDB) REST', () => { + let config: any; + + beforeEach(() => { + config = Config.get('test'); + config.schemaCache.clear(); + }); + + it('supports 2d and 2dsphere indexes', async () => { + const coords = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0], + ]; + const location = geoPoint(10, 10); + // Dedicated class so the index set is deterministic and not polluted by the + // 2dsphere indexes other polygon tests auto-create on a shared class. + const className = 'PolygonIndexTest'; + const databaseAdapter = config.database.adapter; + await databaseAdapter.createIndex(className, { location: '2d' }); + await databaseAdapter.createIndex(className, { polygon: '2dsphere' }); + + const created = await createObject(className, { + location, + polygon: polygon(coords), + polygon2: polygon(coords), + }); + const result = await getObject(className, created.objectId); + expect(result.location).toEqual(location); + expect(result.polygon).toEqual({ __type: 'Polygon', coordinates: coords }); + expect(result.polygon2).toEqual({ __type: 'Polygon', coordinates: coords }); + + // location -> 2d, polygon -> 2dsphere (explicit), polygon2 -> 2dsphere + // (auto-created on save). Assert presence rather than enumeration order. + const indexes = await databaseAdapter.getIndexes(className); + const keys = indexes.map((i: any) => i.key); + expect(indexes.length).toEqual(4); + expect(keys).toContain({ _id: 1 }); + expect(keys).toContain({ location: '2d' }); + expect(keys).toContain({ polygon: '2dsphere' }); + expect(keys).toContain({ polygon2: '2dsphere' }); + }); + + it('stores coordinates as GeoJSON (longitude, latitude) closed rings', async () => { + const input = [ + [12, 11], + [14, 13], + [16, 15], + [18, 17], + ]; + const expected = [ + [ + [11, 12], + [13, 14], + [15, 16], + [17, 18], + [11, 12], + ], + ]; + const created = await createObject('TestObject', { polygon: polygon(input) }); + const raw = await config.database.adapter._rawFind('TestObject', { _id: created.objectId }); + expect(raw.length).toEqual(1); + expect(raw[0].polygon.coordinates).toEqual(expected); + }); + + it('rejects a self-intersecting polygon', async () => { + const coords = [ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + ]; + await expectParseError(createObject('TestObject', { polygon: polygon(coords) })); + }); +}); diff --git a/spec/rest/objects/polygon-query.spec.ts b/spec/rest/objects/polygon-query.spec.ts new file mode 100644 index 0000000000..e71c3afad5 --- /dev/null +++ b/spec/rest/objects/polygon-query.spec.ts @@ -0,0 +1,65 @@ +// Point-in-polygon ($geoIntersects / $point) queries and their validation errors. +// Ported from spec/ParsePolygon.spec.js ("with location" suite). Regression +// coverage for #4608 (coordinate order must not be reversed on query). +import { createObject, createObjects, find } from '../../helpers/client.ts'; +import { geoPoint, polygon, geoIntersects } from '../../helpers/geo.ts'; +import { expectParseError } from '../../helpers/request.ts'; + +describe('Polygon $geoIntersects REST', () => { + it('finds polygons that contain the point', async () => { + await createObjects('TestObject', [ + { boundary: polygon([[0, 0], [0, 1], [1, 1], [1, 0]]) }, + { boundary: polygon([[0, 0], [0, 2], [2, 2], [2, 0]]) }, + { boundary: polygon([[10, 10], [10, 15], [15, 15], [15, 10], [10, 10]]) }, + ]); + const results = await find('TestObject', { + where: { boundary: geoIntersects(geoPoint(0.5, 0.5)) }, + }); + expect(results.length).toEqual(2); + }); + + it('does not reverse the point coordinates (regression #4608)', async () => { + await createObjects('TestObject', [ + { boundary: polygon([[0.25, 0], [0.25, 1.25], [0.75, 1.25], [0.75, 0]]) }, + { boundary: polygon([[0, 0], [0, 2], [2, 2], [2, 0]]) }, + { boundary: polygon([[10, 10], [10, 15], [15, 15], [15, 10], [10, 10]]) }, + ]); + const results = await find('TestObject', { + where: { boundary: geoIntersects(geoPoint(0.5, 1.0)) }, + }); + expect(results.length).toEqual(2); + }); + + it('matches a real-world polygon (regression #4608)', async () => { + const detroit = [ + [42.631655189280224, -83.78406753121705], + [42.633047793854814, -83.75333640366955], + [42.61625254348911, -83.75149921669944], + [42.61526926650296, -83.78161794858735], + [42.631655189280224, -83.78406753121705], + ]; + await createObject('TestObject', { boundary: polygon(detroit) }); + const results = await find('TestObject', { + where: { boundary: geoIntersects(geoPoint(42.624599, -83.770162)) }, + }); + expect(results.length).toEqual(1); + }); + + it('rejects an out-of-bounds point', async () => { + await createObject('TestObject', { + boundary: polygon([[0, 0], [0, 1], [1, 1], [1, 0]]), + }); + await expectParseError( + find('TestObject', { where: { boundary: geoIntersects(geoPoint(181, 181)) } }) + ); + }); + + it('rejects a malformed point', async () => { + await createObject('TestObject', { + boundary: polygon([[0, 0], [0, 1], [1, 1], [1, 0]]), + }); + await expectParseError( + find('TestObject', { where: { boundary: geoIntersects([]) } }) + ); + }); +}); diff --git a/spec/rest/objects/polygon.spec.ts b/spec/rest/objects/polygon.spec.ts new file mode 100644 index 0000000000..24ef25ef3e --- /dev/null +++ b/spec/rest/objects/polygon.spec.ts @@ -0,0 +1,93 @@ +// Polygon object lifecycle over REST (save / get / update / equalTo / validation). +// Ported from spec/ParsePolygon.spec.js. +import { createObject, getObject, updateObject, find } from '../../helpers/client.ts'; +import { polygon } from '../../helpers/geo.ts'; +import { expectParseError } from '../../helpers/request.ts'; + +const OPEN = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], +]; +const CLOSED = [...OPEN, [0, 0]]; + +describe('Polygon REST', () => { + it('saves an open path and reads it back closed', async () => { + const created = await createObject('TestObject', { polygon: polygon(OPEN) }); + const result = await getObject('TestObject', created.objectId); + expect(result.polygon.__type).toEqual('Polygon'); + expect(result.polygon.coordinates).toEqual(CLOSED); + }); + + it('saves an already-closed path unchanged', async () => { + const created = await createObject('TestObject', { polygon: polygon(CLOSED) }); + const result = await getObject('TestObject', created.objectId); + expect(result.polygon.__type).toEqual('Polygon'); + expect(result.polygon.coordinates).toEqual(CLOSED); + }); + + it_id('3019353b-d5b3-4e53-bcb1-716418328bdd')(it)( + 'matches equalTo with either the open or closed path', + async () => { + await createObject('TestObject', { polygon: polygon(OPEN) }); + + const openMatches = await find('TestObject', { where: { polygon: polygon(OPEN) } }); + expect(openMatches.length).toEqual(1); + expect(openMatches[0].polygon.coordinates).toEqual(CLOSED); + + const closedMatches = await find('TestObject', { where: { polygon: polygon(CLOSED) } }); + expect(closedMatches.length).toEqual(1); + expect(closedMatches[0].polygon.coordinates).toEqual(CLOSED); + } + ); + + it('updates a polygon', async () => { + const created = await createObject('TestObject', { polygon: polygon(OPEN) }); + const newCoords = [ + [2, 2], + [2, 3], + [3, 3], + [3, 2], + ]; + await updateObject('TestObject', created.objectId, { polygon: polygon(newCoords) }); + const result = await getObject('TestObject', created.objectId); + expect(result.polygon.coordinates).toEqual([...newCoords, [2, 2]]); + }); + + it('saves a counterclockwise path', async () => { + const coords = [ + [1, 1], + [0, 1], + [0, 0], + [1, 0], + ]; + const created = await createObject('TestObject', { polygon: polygon(coords) }); + const result = await getObject('TestObject', created.objectId); + expect(result.polygon.coordinates).toEqual([...coords, [1, 1]]); + }); + + it('rejects non-numeric coordinates', async () => { + const coords = [ + ['foo', 'bar'], + [0, 1], + [1, 0], + [1, 1], + [0, 0], + ]; + await expectParseError(createObject('TestObject', { polygon: polygon(coords) })); + }); + + it('rejects fewer than three points', async () => { + await expectParseError(createObject('TestObject', { polygon: polygon([[0, 0]]) })); + }); + + it('rejects fewer than three distinct points', async () => { + const coords = [ + [0, 0], + [0, 1], + [0, 0], + ]; + await expectParseError(createObject('TestObject', { polygon: polygon(coords) })); + }); +});