mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 05:11:46 +00:00
* fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump * fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4) * test(nest): cover controller/service branches to clear the 80% coverage gate
This commit is contained in:
Generated
+82
-176
@@ -2475,9 +2475,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2495,9 +2492,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2515,9 +2509,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2535,9 +2526,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2554,9 +2542,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2573,9 +2558,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2593,9 +2575,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2619,9 +2598,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2645,9 +2621,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2671,9 +2644,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2696,9 +2666,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2721,9 +2688,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2896,6 +2860,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jimp/core": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz",
|
||||
@@ -5050,9 +5024,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5070,9 +5041,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5090,9 +5058,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5110,9 +5075,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5130,9 +5092,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5150,9 +5109,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5402,7 +5358,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5416,7 +5373,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5430,7 +5388,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5444,7 +5403,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5458,7 +5418,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5472,7 +5433,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -5482,14 +5444,12 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -5499,14 +5459,12 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5516,14 +5474,12 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5532,9 +5488,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5549,14 +5502,12 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5566,14 +5517,12 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5583,14 +5532,12 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5600,14 +5547,12 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5617,14 +5562,12 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5634,14 +5577,12 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5651,14 +5592,12 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5668,14 +5607,12 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5684,9 +5621,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5705,7 +5639,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5719,7 +5654,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -5733,7 +5669,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -5747,7 +5684,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5761,7 +5699,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -5775,7 +5714,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
@@ -5909,9 +5849,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5929,9 +5866,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5949,9 +5883,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5969,9 +5900,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5989,9 +5917,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6009,9 +5934,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7088,6 +7010,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-istanbul": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.9.tgz",
|
||||
"integrity": "sha512-4a7DsIwycTf4eYwEDtnMfMV8H80KSKH9PuMHhqL5SwPZzDyUKq2X/TPCVZ7NqIuSz7UbZckmEmkip6iZBI/gEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.13",
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||
@@ -12558,9 +12505,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -12582,9 +12526,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -12606,9 +12547,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -12630,9 +12568,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -16923,9 +16858,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -16943,9 +16875,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -16963,9 +16892,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -16989,9 +16915,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -20432,6 +20355,7 @@
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-istanbul": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
@@ -20886,9 +20810,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -20906,9 +20827,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -20926,9 +20844,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -20946,9 +20861,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -20966,9 +20878,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -20986,9 +20895,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
+11
-10
@@ -21,13 +21,12 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trek/shared": "*",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
@@ -47,6 +46,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.4",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
@@ -67,16 +67,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -94,9 +87,17 @@
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-istanbul": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"nodemon": "^3.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^4.1.9"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AddonsController } from '../../../src/nest/addons/addons.controller';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
|
||||
function makeService(overrides: Partial<AddonsService> = {}): AddonsService {
|
||||
return {
|
||||
list: vi.fn().mockReturnValue({ collabFeatures: {}, bagTracking: false, addons: [] }),
|
||||
...overrides,
|
||||
} as unknown as AddonsService;
|
||||
}
|
||||
|
||||
describe('AddonsController (parity with the legacy GET /api/addons route)', () => {
|
||||
it('GET / delegates straight to the service and returns its feed', () => {
|
||||
const feed = {
|
||||
collabFeatures: { comments: true },
|
||||
bagTracking: true,
|
||||
addons: [{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }],
|
||||
};
|
||||
const list = vi.fn().mockReturnValue(feed);
|
||||
const svc = makeService({ list } as Partial<AddonsService>);
|
||||
|
||||
expect(new AddonsController(svc).list()).toBe(feed);
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(list).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Three distinct prepare(...).all() reads (addons, photo_providers, photo_provider_fields).
|
||||
// A single shared statement is reused, so .all() is fed result sets in call order.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({
|
||||
getBagTracking: vi.fn(() => ({ enabled: false })),
|
||||
getCollabFeatures: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock('../../../src/services/adminService', () => ({ getBagTracking, getCollabFeatures }));
|
||||
|
||||
const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) }));
|
||||
vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
||||
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
|
||||
function svc() {
|
||||
return new AddonsService();
|
||||
}
|
||||
|
||||
// Feed the three reads in order: addons, providers, fields.
|
||||
function feedReads(addons: unknown[], providers: unknown[], fields: unknown[]) {
|
||||
dbMock._stmt.all
|
||||
.mockReturnValueOnce(addons)
|
||||
.mockReturnValueOnce(providers)
|
||||
.mockReturnValueOnce(fields);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMock._stmt.all.mockReturnValue([]);
|
||||
getCollabFeatures.mockReturnValue({});
|
||||
getBagTracking.mockReturnValue({ enabled: false });
|
||||
getPhotoProviderConfig.mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('AddonsService.list', () => {
|
||||
it('returns the collab features and the bag-tracking flag from the admin service', () => {
|
||||
getCollabFeatures.mockReturnValue({ comments: true });
|
||||
getBagTracking.mockReturnValue({ enabled: true });
|
||||
feedReads([], [], []);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.collabFeatures).toEqual({ comments: true });
|
||||
expect(res.bagTracking).toBe(true);
|
||||
expect(res.addons).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces the addon enabled column to a boolean (both 1 and 0)', () => {
|
||||
feedReads(
|
||||
[
|
||||
{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 },
|
||||
{ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons).toEqual([
|
||||
{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true },
|
||||
{ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[],
|
||||
);
|
||||
getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' });
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons).toEqual([
|
||||
{
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
icon: 'image',
|
||||
enabled: true,
|
||||
config: { baseUrl: 'http://x' },
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
expect(getPhotoProviderConfig).toHaveBeenCalledWith('immich');
|
||||
});
|
||||
|
||||
it('coerces a disabled photo provider enabled flag to false', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'synology', name: 'Synology', icon: 'image', enabled: 0, sort_order: 1 }],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect((res.addons[0] as { enabled: boolean }).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('groups multiple fields under their provider and keeps insertion order', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'immich',
|
||||
field_key: 'url',
|
||||
label: 'URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://',
|
||||
hint: 'Base URL',
|
||||
required: 1,
|
||||
secret: 0,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: 'url',
|
||||
sort_order: 0,
|
||||
},
|
||||
// Second field for the SAME provider exercises the `get(...) || []` truthy branch.
|
||||
{
|
||||
provider_id: 'immich',
|
||||
field_key: 'token',
|
||||
label: 'Token',
|
||||
input_type: 'password',
|
||||
placeholder: null,
|
||||
hint: null,
|
||||
required: 0,
|
||||
secret: 1,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
const provider = res.addons[0] as { fields: Array<Record<string, unknown>> };
|
||||
expect(provider.fields).toEqual([
|
||||
{
|
||||
key: 'url',
|
||||
label: 'URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://',
|
||||
hint: 'Base URL',
|
||||
required: true,
|
||||
secret: false,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: 'url',
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
key: 'token',
|
||||
label: 'Token',
|
||||
input_type: 'password',
|
||||
placeholder: '',
|
||||
hint: null,
|
||||
required: false,
|
||||
secret: true,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'p', name: 'P', icon: 'i', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'p',
|
||||
field_key: 'k',
|
||||
label: 'L',
|
||||
input_type: 'text',
|
||||
// placeholder/hint/settings_key/payload_key omitted entirely (undefined)
|
||||
required: 0,
|
||||
secret: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
const field = (res.addons[0] as { fields: Array<Record<string, unknown>> }).fields[0];
|
||||
expect(field).toMatchObject({
|
||||
placeholder: '',
|
||||
hint: null,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps fields belonging to other providers out of a provider with none of its own', () => {
|
||||
// A field exists, but for a DIFFERENT provider than the one returned — exercises
|
||||
// the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty.
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'has-none', name: 'X', icon: 'i', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'other',
|
||||
field_key: 'k',
|
||||
label: 'L',
|
||||
input_type: 'text',
|
||||
required: 0,
|
||||
secret: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect((res.addons[0] as { fields: unknown[] }).fields).toEqual([]);
|
||||
});
|
||||
|
||||
it('concatenates regular addons before the photo providers', () => {
|
||||
feedReads(
|
||||
[{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']);
|
||||
expect((res.addons[1] as { type: string }).type).toBe('photo_provider');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mock
|
||||
import { AdminController } from '../../../src/nest/admin/admin.controller';
|
||||
import type { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import { send as sendNotification } from '../../../src/services/notificationService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User;
|
||||
@@ -121,6 +122,114 @@ describe('AdminController addons + sessions + jwt + defaults', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController error envelope fallbacks', () => {
|
||||
it('ok() defaults to 400 when the error envelope omits a status', () => {
|
||||
expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } });
|
||||
});
|
||||
|
||||
it('updateOidc defaults to 400 when the service error omits a status', () => {
|
||||
expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'nope' }) } as Partial<AdminService>)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'nope' } });
|
||||
});
|
||||
|
||||
it('updateOidc audits issuer_set=false when no issuer is supplied', () => {
|
||||
expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).updateOidc(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.oidc_update', details: { issuer_set: false } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController read-only getters', () => {
|
||||
it('return service values verbatim', () => {
|
||||
expect(new AdminController(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
|
||||
expect(new AdminController(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
|
||||
expect(new AdminController(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
|
||||
expect(new AdminController(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
|
||||
expect(new AdminController(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial<AdminService>)).getOidc()).toEqual({ issuer: 'x' });
|
||||
expect(new AdminController(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
|
||||
expect(new AdminController(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial<AdminService>)).getNotificationPrefs(user)).toEqual({ rows: [] });
|
||||
expect(new AdminController(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).getBagTracking()).toEqual({ enabled: false });
|
||||
expect(new AdminController(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesPhotos()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesAutocomplete()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesDetails()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial<AdminService>)).getCollabFeatures()).toEqual({ chat: false });
|
||||
expect(new AdminController(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
|
||||
expect(new AdminController(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>)).getDefaultUserSettings()).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('setNotificationPrefs persists then returns the refreshed matrix', () => {
|
||||
const setAdminPreferences = vi.fn();
|
||||
const c = new AdminController(svc({ setAdminPreferences, getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [1] }) } as Partial<AdminService>));
|
||||
expect(c.setNotificationPrefs(user, { x: 1 })).toEqual({ rows: [1] });
|
||||
expect(setAdminPreferences).toHaveBeenCalledWith(user.id, { x: 1 });
|
||||
});
|
||||
|
||||
it('githubReleases falls back to default paging when no query is given', async () => {
|
||||
const getGithubReleases = vi.fn().mockResolvedValue([{ tag: 'v1' }]);
|
||||
const c = new AdminController(svc({ getGithubReleases } as Partial<AdminService>));
|
||||
await expect(c.githubReleases()).resolves.toEqual([{ tag: 'v1' }]);
|
||||
expect(getGithubReleases).toHaveBeenCalledWith('10', '1');
|
||||
await c.githubReleases('5', '2');
|
||||
expect(getGithubReleases).toHaveBeenLastCalledWith('5', '2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController feature toggles + audit', () => {
|
||||
it('bag-tracking updates and audits', () => {
|
||||
const c = new AdminController(svc({ updateBagTracking: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>));
|
||||
expect(c.updateBagTracking(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.bag_tracking' }));
|
||||
});
|
||||
|
||||
it('places-autocomplete: 400 on a non-boolean, else updates + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).updatePlacesAutocomplete(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } });
|
||||
expect(new AdminController(svc({ updatePlacesAutocomplete: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).updatePlacesAutocomplete(user, { enabled: false }, req)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('places-details: 400 on a non-boolean, else updates + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).updatePlacesDetails(user, { enabled: 1 }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } });
|
||||
expect(new AdminController(svc({ updatePlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).updatePlacesDetails(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController packing template sub-routes', () => {
|
||||
it('update/delete templates, categories and items map errors + return success', () => {
|
||||
expect(new AdminController(svc({ updatePackingTemplate: vi.fn().mockReturnValue({ id: 3 }) } as Partial<AdminService>)).updatePackingTemplate('3', {})).toEqual({ id: 3 });
|
||||
expect(new AdminController(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 });
|
||||
expect(new AdminController(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 });
|
||||
expect(new AdminController(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateCategory('3', '4')).toEqual({ success: true });
|
||||
expect(new AdminController(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).updateTemplateItem('7', {})).toEqual({ id: 7 });
|
||||
expect(new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateItem('7')).toEqual({ success: true });
|
||||
expect(thrown(() => new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial<AdminService>)).deleteTemplateItem('9'))).toEqual({ status: 404, body: { error: 'gone' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController tokens + sessions', () => {
|
||||
it('mcp token + oauth session deletes return success and map errors', () => {
|
||||
expect(new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteMcpToken('2')).toEqual({ success: true });
|
||||
expect(thrown(() => new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial<AdminService>)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } });
|
||||
expect(thrown(() => new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial<AdminService>)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController default-user-settings error path', () => {
|
||||
it('400 with an Error message when setAdminUserDefaults throws an Error', () => {
|
||||
const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw new Error('bad default'); }) } as Partial<AdminService>));
|
||||
expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'bad default' } });
|
||||
});
|
||||
|
||||
it('400 stringifies a non-Error throw', () => {
|
||||
const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw 'plain string'; }) } as Partial<AdminService>));
|
||||
expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'plain string' } });
|
||||
});
|
||||
|
||||
it('400 when the body is null', () => {
|
||||
expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, null, req))).toEqual({ status: 400, body: { error: 'Object body required' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController dev test-notification', () => {
|
||||
it('404 outside development', async () => {
|
||||
delete process.env.NODE_ENV;
|
||||
@@ -132,4 +241,23 @@ describe('AdminController dev test-notification', () => {
|
||||
const res = await new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' });
|
||||
expect(res).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('applies notification defaults when the body is empty', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const res = await new AdminController(svc()).devTestNotification(user, {});
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ event: 'trip_reminder', scope: 'user', targetId: user.id }));
|
||||
});
|
||||
|
||||
it('maps an Error from the notification service to 400', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
(sendNotification as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('send failed'));
|
||||
await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'send failed' } });
|
||||
});
|
||||
|
||||
it('stringifies a non-Error notification failure to 400', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
(sendNotification as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce('weird');
|
||||
await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,264 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
vi.mock('../../../src/middleware/auth', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles: vi.fn() }));
|
||||
vi.mock('../../../src/services/cookie', () => ({ setAuthCookie: vi.fn() }));
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../../src/services/passkeyService', () => ({
|
||||
passkeyRegisterOptions: vi.fn(),
|
||||
passkeyRegisterVerify: vi.fn(),
|
||||
passkeyLoginOptions: vi.fn(),
|
||||
passkeyLoginVerify: vi.fn(),
|
||||
listPasskeys: vi.fn(),
|
||||
renamePasskey: vi.fn(),
|
||||
deletePasskey: vi.fn(),
|
||||
}));
|
||||
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import { CookieAuthGuard } from '../../../src/nest/auth/cookie-auth.guard';
|
||||
import { OptionalJwtGuard } from '../../../src/nest/auth/optional-jwt.guard';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
import { PasskeyEnabledGuard } from '../../../src/nest/auth/passkey-enabled.guard';
|
||||
import { PasskeyController } from '../../../src/nest/auth/passkey.controller';
|
||||
import { RateLimitService } from '../../../src/nest/auth/rate-limit.service';
|
||||
import { CurrentUser } from '../../../src/nest/auth/current-user.decorator';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../../../src/middleware/auth';
|
||||
import { resolveAuthToggles } from '../../../src/services/authService';
|
||||
import { setAuthCookie } from '../../../src/services/cookie';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import * as passkey from '../../../src/services/passkeyService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function context(req: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => req }) } as never;
|
||||
}
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
|
||||
it('rejects with the legacy 401 { error, code } when no token is present', () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
guard.canActivate(context({ headers: {}, cookies: {} }));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HttpException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(401);
|
||||
expect((thrown as HttpException).getResponse()).toEqual({
|
||||
error: 'Access token required',
|
||||
code: 'AUTH_REQUIRED',
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Access token required', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid/expired token (verify returns null)', () => {
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ headers: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Invalid or expired token', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the loaded user and allows a valid token through', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CookieAuthGuard', () => {
|
||||
const guard = new CookieAuthGuard();
|
||||
|
||||
it('401s when the trek_session cookie is missing', () => {
|
||||
expect(thrown(() => guard.canActivate(context({ cookies: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
|
||||
});
|
||||
// and when there is no cookies object at all
|
||||
expect(thrown(() => guard.canActivate(context({})))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('401s when the cookie token fails verification', () => {
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Invalid or expired session', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the user and allows a valid cookie session through', () => {
|
||||
const req: Record<string, unknown> = { cookies: { trek_session: 'tok' } };
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OptionalJwtGuard', () => {
|
||||
const guard = new OptionalJwtGuard();
|
||||
|
||||
it('always allows; sets req.user to null when no token', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBeNull();
|
||||
expect(verifyJwtAndLoadUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets req.user to null when a token verifies to nothing', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBeNull();
|
||||
});
|
||||
|
||||
it('populates req.user from a valid token', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
const guard = new AdminGuard();
|
||||
|
||||
it('403s for anonymous and for a non-admin role', () => {
|
||||
expect(thrown(() => guard.canActivate(context({})))).toEqual({ status: 403, body: { error: 'Admin access required' } });
|
||||
expect(thrown(() => guard.canActivate(context({ user: { role: 'user' } })))).toEqual({ status: 403, body: { error: 'Admin access required' } });
|
||||
});
|
||||
|
||||
it('allows an admin through', () => {
|
||||
expect(guard.canActivate(context({ user: { role: 'admin' } }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasskeyEnabledGuard', () => {
|
||||
const guard = new PasskeyEnabledGuard();
|
||||
|
||||
it('404s when passkey_login is off', () => {
|
||||
vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: false } as ReturnType<typeof resolveAuthToggles>);
|
||||
expect(thrown(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } });
|
||||
});
|
||||
|
||||
it('allows when passkey_login is on', () => {
|
||||
vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: true } as ReturnType<typeof resolveAuthToggles>);
|
||||
expect(guard.canActivate()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CurrentUser decorator', () => {
|
||||
// Apply the decorator to a throwaway handler so Nest stores the param factory in
|
||||
// route metadata, then invoke that factory exactly as the framework would.
|
||||
function paramFactory(): (data: unknown, ctx: unknown) => User | undefined {
|
||||
class Target { handler(_u: User) {} }
|
||||
(CurrentUser() as ParameterDecorator)(Target.prototype, 'handler', 0);
|
||||
const meta = Reflect.getMetadata('__routeArguments__', Target, 'handler') as Record<string, { factory: (data: unknown, ctx: unknown) => User | undefined }>;
|
||||
return Object.values(meta)[0].factory;
|
||||
}
|
||||
|
||||
it('resolves the authenticated user from the request', () => {
|
||||
expect(paramFactory()(undefined, context({ user }))).toBe(user);
|
||||
});
|
||||
|
||||
it('returns undefined when no user is attached', () => {
|
||||
expect(paramFactory()(undefined, context({}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasskeyController', () => {
|
||||
const req = { ip: '9.9.9.9' } as Request;
|
||||
const res = {} as never;
|
||||
function rl(): RateLimitService { return new RateLimitService(); }
|
||||
|
||||
it('register/options maps a service error, else returns the options', async () => {
|
||||
vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ error: 'Incorrect password', status: 401 });
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
|
||||
vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ options: { challenge: 'c' } as never });
|
||||
expect(await new PasskeyController(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' });
|
||||
});
|
||||
|
||||
it('register/verify maps a service error, else audits and returns the credential', async () => {
|
||||
vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ error: 'Verification failed', status: 400 } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } });
|
||||
vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ credential: { id: 'cr' } } as never);
|
||||
expect(await new PasskeyController(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_register' }));
|
||||
});
|
||||
|
||||
it('login/options maps a service error, else returns the options', async () => {
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ error: 'Not configured', status: 503 } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } });
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'd' } } as never);
|
||||
expect(await new PasskeyController(rl()).loginOptions(req)).toEqual({ challenge: 'd' });
|
||||
});
|
||||
|
||||
it('login/verify audits a failure then maps the error, padding latency', async () => {
|
||||
vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_fail' }));
|
||||
}, 10000);
|
||||
|
||||
it('login/verify sets the session cookie and audits login on success', async () => {
|
||||
vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never);
|
||||
expect(await new PasskeyController(rl()).loginVerify({}, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req);
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } }));
|
||||
}, 10000);
|
||||
|
||||
it('credentials: list, rename (error + success), delete (error + success)', () => {
|
||||
vi.mocked(passkey.listPasskeys).mockReturnValue([{ id: 'a' }]);
|
||||
expect(new PasskeyController(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] });
|
||||
|
||||
vi.mocked(passkey.renamePasskey).mockReturnValue({ error: 'Not found', status: 404 });
|
||||
expect(thrown(() => new PasskeyController(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
vi.mocked(passkey.renamePasskey).mockReturnValue({ success: true });
|
||||
expect(new PasskeyController(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true });
|
||||
|
||||
vi.mocked(passkey.deletePasskey).mockReturnValue({ error: 'Incorrect password', status: 401 });
|
||||
expect(thrown(() => new PasskeyController(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
|
||||
vi.mocked(passkey.deletePasskey).mockReturnValue({ success: true });
|
||||
expect(new PasskeyController(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' }));
|
||||
});
|
||||
|
||||
it('throttles registration and login ceremonies once the bucket is exhausted', async () => {
|
||||
const s = new RateLimitService();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 5; i++) s.check('mfa', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
||||
expect(await thrownAsync(() => new PasskeyController(s).registerOptions(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
|
||||
const s2 = new RateLimitService();
|
||||
for (let i = 0; i < 10; i++) s2.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
|
||||
expect(await thrownAsync(() => new PasskeyController(s2).loginOptions(req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('falls back to the "unknown" rate-limit key when req.ip is absent', async () => {
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'z' } } as never);
|
||||
const noIp = {} as Request;
|
||||
expect(await new PasskeyController(rl()).loginOptions(noIp)).toEqual({ challenge: 'z' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,18 @@ describe('RateLimitService', () => {
|
||||
expect(s.check('mfa', 'ip', 2, 1000, 20)).toBe(true); // different bucket
|
||||
expect(s.check('login', 'ip', 2, 1000, 2000)).toBe(true); // window elapsed -> reset
|
||||
});
|
||||
|
||||
it('reset clears a single named bucket, and reset() clears all of them', () => {
|
||||
const s = rl();
|
||||
s.check('login', 'ip', 1, 1000, 0); // login bucket now at its cap
|
||||
s.check('mfa', 'ip', 1, 1000, 0); // mfa bucket now at its cap
|
||||
expect(s.check('login', 'ip', 1, 1000, 0)).toBe(false);
|
||||
s.reset('login'); // only the login bucket
|
||||
expect(s.check('login', 'ip', 1, 1000, 0)).toBe(true);
|
||||
expect(s.check('mfa', 'ip', 1, 1000, 0)).toBe(false); // mfa untouched
|
||||
s.reset(); // everything
|
||||
expect(s.check('mfa', 'ip', 1, 1000, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthPublicController', () => {
|
||||
@@ -104,6 +116,71 @@ describe('AuthPublicController', () => {
|
||||
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('app-config forwards the optional user (present and absent)', () => {
|
||||
const getAppConfig = vi.fn().mockReturnValue({ version: '3' });
|
||||
const c = new AuthPublicController(asvc({ getAppConfig } as Partial<AuthService>), rl());
|
||||
expect(c.appConfig({ user } as unknown as Request)).toEqual({ version: '3' });
|
||||
expect(getAppConfig).toHaveBeenLastCalledWith(user);
|
||||
expect(c.appConfig({} as Request)).toEqual({ version: '3' });
|
||||
expect(getAppConfig).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('invite maps a service error', () => {
|
||||
const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial<AuthService>), rl());
|
||||
expect(thrown(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } });
|
||||
});
|
||||
|
||||
it('login takes the mfa-required branch and never sets a cookie', async () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(await c.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
||||
expect(setAuthCookie).not.toHaveBeenCalled();
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' }));
|
||||
}, 10000);
|
||||
|
||||
it('forgot-password: non-issued reason and a delivery failure both still return ok', async () => {
|
||||
// Non-issued (unknown email / throttled): audits the reason, no email sent.
|
||||
const sendNever = vi.fn();
|
||||
const skip = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial<AuthService>), rl());
|
||||
expect(await skip.forgotPassword({ email: 'x@y.z' }, req)).toEqual({ ok: true });
|
||||
expect(sendNever).not.toHaveBeenCalled();
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_request', details: { reason: 'not_found' } }));
|
||||
// Issued but the mailer throws: swallowed, audited as failed, still ok.
|
||||
const boom = vi.fn().mockRejectedValue(new Error('smtp'));
|
||||
const fail = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial<AuthService>), rl());
|
||||
expect(await fail.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ details: { delivered: 'failed' } }));
|
||||
}, 10000);
|
||||
|
||||
it('forgot-password ignores a non-string email body', async () => {
|
||||
const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null });
|
||||
const c = new AuthPublicController(asvc({ requestPasswordReset } as Partial<AuthService>), rl());
|
||||
expect(await c.forgotPassword({ email: 42 } as { email?: unknown }, req)).toEqual({ ok: true });
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String));
|
||||
}, 10000);
|
||||
|
||||
it('reset-password 429 once the dedicated reset bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 5; i++) s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
||||
const c = new AuthPublicController(asvc({ resetPassword: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.resetPassword({}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('mfa/verify-login maps a service error', () => {
|
||||
const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial<AuthService>), rl());
|
||||
expect(thrown(() => c.verifyMfaLogin({}, req, res))).toEqual({ status: 401, body: { error: 'Bad code' } });
|
||||
});
|
||||
|
||||
it('demo-login + register + invite throw 429 when the login bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
|
||||
const c = new AuthPublicController(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.register({}, req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('mfa/verify-login sets cookie + audits; logout clears cookie', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
@@ -167,4 +244,88 @@ describe('AuthController (authenticated)', () => {
|
||||
const c = new AuthController(asvc({ changePassword: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('change-password refreshes this device cookie when the service returns a token', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.changePassword(user, {}, req, res)).toEqual({ success: true });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req);
|
||||
});
|
||||
|
||||
it('delete-account maps error, else audits and succeeds', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
|
||||
expect(new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteAccount(user, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' }));
|
||||
});
|
||||
|
||||
it('maps-key + api-keys pass straight through to the service', () => {
|
||||
const updateMapsKey = vi.fn().mockReturnValue({ success: true });
|
||||
expect(new AuthController(asvc({ updateMapsKey } as Partial<AuthService>), rl()).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true });
|
||||
expect(updateMapsKey).toHaveBeenCalledWith(1, 'k');
|
||||
const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 });
|
||||
expect(new AuthController(asvc({ updateApiKeys } as Partial<AuthService>), rl()).apiKeys(user, { a: 1 })).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it('update-settings + get-settings map errors, else return their payloads', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) } as Partial<AuthService>), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } });
|
||||
expect(new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial<AuthService>), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } });
|
||||
expect(thrown(() => new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial<AuthService>), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
|
||||
expect(new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial<AuthService>), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
|
||||
});
|
||||
|
||||
it('delete-avatar + users + travel-stats delegate to the service', async () => {
|
||||
const deleteAvatar = vi.fn().mockResolvedValue({ removed: true });
|
||||
expect(await new AuthController(asvc({ deleteAvatar } as Partial<AuthService>), rl()).deleteAvatar(user)).toEqual({ removed: true });
|
||||
const listUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(new AuthController(asvc({ listUsers } as Partial<AuthService>), rl()).users(user)).toEqual({ users: [{ id: 1 }] });
|
||||
expect(listUsers).toHaveBeenCalledWith(1);
|
||||
const getTravelStats = vi.fn().mockReturnValue({ countries: 3 });
|
||||
expect(new AuthController(asvc({ getTravelStats } as Partial<AuthService>), rl()).travelStats(user)).toEqual({ countries: 3 });
|
||||
});
|
||||
|
||||
it('validate-keys maps error, else returns the maps/weather payload', async () => {
|
||||
expect(await thrownAsync(() => new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) } as Partial<AuthService>), rl()).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } });
|
||||
const ok = new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) } as Partial<AuthService>), rl());
|
||||
expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } });
|
||||
});
|
||||
|
||||
it('app-settings get maps error, else returns data; put maps error, else audits', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
||||
expect(new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
||||
expect(thrown(() => new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
||||
expect(new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' }));
|
||||
});
|
||||
|
||||
it('mfa/setup maps a service error before ever awaiting the QR promise', async () => {
|
||||
const c = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } });
|
||||
});
|
||||
|
||||
it('mfa/enable + mfa/disable map errors', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial<AuthService>), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } });
|
||||
expect(thrown(() => new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial<AuthService>), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } });
|
||||
const ok = new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), rl());
|
||||
expect(ok.mfaDisable(user, {}, req)).toEqual({ success: true, mfa_enabled: false });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' }));
|
||||
});
|
||||
|
||||
it('mcp-tokens list + create error + delete error/success', () => {
|
||||
expect(new AuthController(asvc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) } as Partial<AuthService>), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
|
||||
expect(thrown(() => new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial<AuthService>), rl()).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
||||
expect(thrown(() => new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) } as Partial<AuthService>), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
expect(new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteMcpToken(user, 'tid')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('ws-token maps error, else returns the token', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) } as Partial<AuthService>), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
|
||||
expect(new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial<AuthService>), rl()).wsToken(user)).toEqual({ token: 'ws' });
|
||||
});
|
||||
|
||||
it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockReturnValue(false);
|
||||
const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/b.png' });
|
||||
expect(await new AuthController(asvc({ saveAvatar } as Partial<AuthService>), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,31 @@ import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
// The controller imports the tmp-dir + size cap at module load.
|
||||
vi.mock('../../../src/services/backupService', () => ({ getUploadTmpDir: () => '/tmp', MAX_BACKUP_UPLOAD_SIZE: 1024 }));
|
||||
// The controller imports the tmp-dir + size cap at module load. The thin
|
||||
// BackupService wrapper forwards every call straight into this module, so the
|
||||
// mock also stubs the delegated functions for the wrapper tests below.
|
||||
vi.mock('../../../src/services/backupService', () => ({
|
||||
getUploadTmpDir: () => '/tmp',
|
||||
MAX_BACKUP_UPLOAD_SIZE: 1024,
|
||||
BACKUP_RATE_WINDOW: 3600000,
|
||||
listBackups: vi.fn().mockReturnValue([{ filename: 'svc.zip' }]),
|
||||
createBackup: vi.fn().mockResolvedValue({ filename: 'svc.zip', size: 5 }),
|
||||
restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
|
||||
getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: false }, timezone: 'UTC' }),
|
||||
updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }),
|
||||
deleteBackup: vi.fn(),
|
||||
isValidBackupFilename: vi.fn().mockReturnValue(true),
|
||||
backupFilePath: vi.fn().mockReturnValue('/data/backups/svc.zip'),
|
||||
backupFileExists: vi.fn().mockReturnValue(true),
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
import { BackupController } from '../../../src/nest/backup/backup.controller';
|
||||
import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
import type { BackupService } from '../../../src/nest/backup/backup.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import * as backupSvc from '../../../src/services/backupService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'a@example.test' } as User;
|
||||
@@ -86,12 +104,17 @@ describe('BackupController', () => {
|
||||
|
||||
it('POST /restore maps the service status, else audits', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } });
|
||||
const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).restore(user, 'x.zip', req);
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.restore', resource: 'x.zip' }));
|
||||
});
|
||||
|
||||
it('POST /restore falls back to status 400 when the service omits one', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, error: 'nope' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 400, body: { error: 'nope' } });
|
||||
});
|
||||
|
||||
it('POST /upload-restore 400 without a file, cleans up the tmp file', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
});
|
||||
@@ -108,6 +131,14 @@ describe('BackupController', () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } });
|
||||
});
|
||||
|
||||
it('POST /upload-restore falls back to a default name and maps unexpected errors to 500', async () => {
|
||||
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
|
||||
const ok = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
|
||||
await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, ok, req);
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'upload.zip' }));
|
||||
});
|
||||
|
||||
it('maps unexpected service errors to 500 (create, restore, auto-settings)', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await thrownAsync(() => new BackupController(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial<BackupService>)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } });
|
||||
@@ -123,6 +154,20 @@ describe('BackupController', () => {
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
process.env.NODE_ENV = 'production';
|
||||
const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings tolerates a missing body', () => {
|
||||
const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 });
|
||||
new BackupController(svc({ updateAutoSettings } as Partial<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
|
||||
expect(updateAutoSettings).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('GET/PUT /auto-settings', () => {
|
||||
expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
|
||||
const res = new BackupController(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial<BackupService>)).updateAutoSettings(user, { enabled: true }, req);
|
||||
@@ -138,3 +183,50 @@ describe('BackupController', () => {
|
||||
expect(deleteBackup).toHaveBeenCalledWith('x.zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackupService (wrapper)', () => {
|
||||
const wrapper = new RealBackupService();
|
||||
|
||||
it('forwards every call straight to the legacy backup service', async () => {
|
||||
expect(wrapper.listBackups()).toEqual([{ filename: 'svc.zip' }]);
|
||||
expect(backupSvc.listBackups).toHaveBeenCalled();
|
||||
|
||||
await expect(wrapper.createBackup()).resolves.toEqual({ filename: 'svc.zip', size: 5 });
|
||||
expect(backupSvc.createBackup).toHaveBeenCalled();
|
||||
|
||||
await expect(wrapper.restoreFromZip('/tmp/a.zip')).resolves.toEqual({ success: true });
|
||||
expect(backupSvc.restoreFromZip).toHaveBeenCalledWith('/tmp/a.zip');
|
||||
|
||||
expect(wrapper.getAutoSettings()).toEqual({ settings: { enabled: false }, timezone: 'UTC' });
|
||||
expect(backupSvc.getAutoSettings).toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.updateAutoSettings({ enabled: true })).toEqual({ enabled: true, interval: 'daily', keep_days: 7 });
|
||||
expect(backupSvc.updateAutoSettings).toHaveBeenCalledWith({ enabled: true });
|
||||
|
||||
wrapper.deleteBackup('svc.zip');
|
||||
expect(backupSvc.deleteBackup).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.isValidBackupFilename('svc.zip')).toBe(true);
|
||||
expect(backupSvc.isValidBackupFilename).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.backupFilePath('svc.zip')).toBe('/data/backups/svc.zip');
|
||||
expect(backupSvc.backupFilePath).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.backupFileExists('svc.zip')).toBe(true);
|
||||
expect(backupSvc.backupFileExists).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.checkRateLimit('ip', 3, 1000)).toBe(true);
|
||||
expect(backupSvc.checkRateLimit).toHaveBeenCalledWith('ip', 3, 1000);
|
||||
});
|
||||
|
||||
it('exposes the legacy rate window', () => {
|
||||
expect(wrapper.rateWindow).toBe(backupSvc.BACKUP_RATE_WINDOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackupModule', () => {
|
||||
it('wires the controller and service together', async () => {
|
||||
const { BackupModule } = await import('../../../src/nest/backup/backup.module');
|
||||
expect(new BackupModule()).toBeInstanceOf(BackupModule);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,12 +42,75 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
});
|
||||
|
||||
it('GET /summary/per-person + /settlement delegate', () => {
|
||||
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
||||
const svc = makeService({
|
||||
perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]),
|
||||
settlement: vi.fn().mockReturnValue({ transfers: [] }),
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
expect(new BudgetController(svc).settlement(user, '5')).toEqual({ transfers: [] });
|
||||
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR');
|
||||
});
|
||||
|
||||
it('GET /settlement forwards the base query and the trip currency', () => {
|
||||
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
||||
const svc = makeService({
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }),
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
new BudgetController(svc).settlement(user, '5', 'GBP');
|
||||
expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD');
|
||||
});
|
||||
|
||||
describe('settlements ledger', () => {
|
||||
it('GET /settlements lists', () => {
|
||||
const svc = makeService({ listSettlements: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST /settlements 403 without budget_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /settlements 400 when a field is missing', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, amount: 5 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { to_user_id: 2, amount: 5 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /settlements creates and broadcasts (amount 0 is allowed)', () => {
|
||||
const createSettlement = vi.fn().mockReturnValue({ id: 3, amount: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createSettlement, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0 }, 'sock');
|
||||
expect(res).toEqual({ settlement: { id: 3, amount: 0 } });
|
||||
expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0 }, user.id);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE /settlements/:id 404 when missing', () => {
|
||||
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({
|
||||
status: 404, body: { error: 'Settlement not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /settlements/:id success broadcasts the numeric id', () => {
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(true), broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
@@ -124,6 +187,31 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/payers', () => {
|
||||
it('400 when payers is not an array', () => {
|
||||
expect(thrown(() => new BudgetController(makeService()).setPayers(user, '5', '9', 'nope'))).toEqual({
|
||||
status: 400, body: { error: 'payers must be an array' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the item is missing', () => {
|
||||
const svc = makeService({ setPayers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).setPayers(user, '5', '9', [{ user_id: 2, amount: 10 }]))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sets payers and broadcasts budget:updated', () => {
|
||||
const setPayers = vi.fn().mockReturnValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ setPayers, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).setPayers(user, '5', '9', [{ user_id: 2, amount: 10 }], 'sock');
|
||||
expect(res).toEqual({ item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } });
|
||||
expect(setPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', () => {
|
||||
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 1 });
|
||||
const broadcast = vi.fn();
|
||||
@@ -132,6 +220,14 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 1 }, 'sock');
|
||||
});
|
||||
|
||||
it('PUT /:id/members/:userId/paid broadcasts paid: 0 when toggled off', () => {
|
||||
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
|
||||
new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', false, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 0 }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE /:id 404 when missing, success otherwise', () => {
|
||||
const missing = makeService({ remove: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the data + side-effect dependencies the wrapper reaches into directly.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { getRates } = vi.hoisted(() => ({ getRates: vi.fn() }));
|
||||
vi.mock('../../../src/services/exchangeRateService', () => ({ getRates }));
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: {
|
||||
verifyTripAccess: vi.fn(),
|
||||
listBudgetItems: vi.fn(),
|
||||
getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(),
|
||||
createBudgetItem: vi.fn(),
|
||||
updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(),
|
||||
updateMembers: vi.fn(),
|
||||
toggleMemberPaid: vi.fn(),
|
||||
setItemPayers: vi.fn(),
|
||||
listSettlements: vi.fn(),
|
||||
createSettlement: vi.fn(),
|
||||
deleteSettlement: vi.fn(),
|
||||
reorderBudgetItems: vi.fn(),
|
||||
reorderBudgetCategories: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/budgetService', () => budget);
|
||||
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
|
||||
function svc() {
|
||||
return new BudgetService();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('BudgetService', () => {
|
||||
it('verifyTripAccess delegates to the legacy service', () => {
|
||||
budget.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(budget.verifyTripAccess).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
|
||||
it('canEdit forwards the ownership flag when the user owns the trip', () => {
|
||||
checkPermission.mockReturnValue(true);
|
||||
expect(svc().canEdit({ user_id: 1 } as never, { id: 1, role: 'user' } as never)).toBe(true);
|
||||
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 1, 1, false);
|
||||
});
|
||||
|
||||
it('canEdit marks the user as a guest when they do not own the trip', () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
expect(svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never)).toBe(false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('broadcast forwards to the websocket helper', () => {
|
||||
svc().broadcast('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
});
|
||||
|
||||
it('list / perPersonSummary delegate', () => {
|
||||
budget.listBudgetItems.mockReturnValue([{ id: 1 }]);
|
||||
expect(svc().list('5')).toEqual([{ id: 1 }]);
|
||||
budget.getPerPersonSummary.mockReturnValue([{ userId: 1 }]);
|
||||
expect(svc().perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
});
|
||||
|
||||
describe('settlement', () => {
|
||||
it('upper-cases the explicit base and forwards the rates', async () => {
|
||||
getRates.mockResolvedValue({ USD: 1.1 });
|
||||
budget.calculateSettlement.mockReturnValue({ transfers: [] });
|
||||
await svc().settlement('5', 'usd', 'EUR');
|
||||
expect(getRates).toHaveBeenCalledWith('USD');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the trip currency when no base is given', async () => {
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, 'gbp');
|
||||
expect(getRates).toHaveBeenCalledWith('GBP');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
});
|
||||
|
||||
it('falls back to EUR when neither base nor trip currency is present', async () => {
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, '');
|
||||
expect(getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
});
|
||||
});
|
||||
|
||||
it('create / update / remove / members / paid / payers delegate', () => {
|
||||
svc().create('5', { name: 'Hotel' } as never);
|
||||
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
svc().update('9', '5', { name: 'X' });
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' });
|
||||
svc().remove('9', '5');
|
||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5');
|
||||
svc().updateMembers('9', '5', [2, 3]);
|
||||
expect(budget.updateMembers).toHaveBeenCalledWith('9', '5', [2, 3]);
|
||||
svc().toggleMemberPaid('9', '5', '2', true);
|
||||
expect(budget.toggleMemberPaid).toHaveBeenCalledWith('9', '5', '2', true);
|
||||
svc().setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(budget.setItemPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
});
|
||||
|
||||
it('settlement ledger + reorder delegate', () => {
|
||||
svc().listSettlements('5');
|
||||
expect(budget.listSettlements).toHaveBeenCalledWith('5');
|
||||
svc().createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
expect(budget.createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
svc().deleteSettlement('7', '5');
|
||||
expect(budget.deleteSettlement).toHaveBeenCalledWith('7', '5');
|
||||
svc().reorderItems('5', [3, 1]);
|
||||
expect(budget.reorderBudgetItems).toHaveBeenCalledWith('5', [3, 1]);
|
||||
svc().reorderCategories('5', ['food', 'fun']);
|
||||
expect(budget.reorderBudgetCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
|
||||
});
|
||||
|
||||
describe('syncReservationPrice', () => {
|
||||
it('returns early when the reservation is not found', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
svc().syncReservationPrice('5', 42, 250, 'sock');
|
||||
expect(dbMock._stmt.run).not.toHaveBeenCalled();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges into existing metadata and broadcasts reservation:updated', () => {
|
||||
dbMock._stmt.get
|
||||
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup
|
||||
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
|
||||
svc().syncReservationPrice('5', 42, 250, 'sock');
|
||||
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
||||
expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock');
|
||||
});
|
||||
|
||||
it('starts from an empty object when the reservation has no metadata', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 });
|
||||
svc().syncReservationPrice('5', 42, 99, undefined);
|
||||
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
||||
expect(writtenMeta).toEqual({ price: '99' });
|
||||
});
|
||||
|
||||
it('swallows errors so a sync failure never breaks the budget update', () => {
|
||||
dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); });
|
||||
expect(() => svc().syncReservationPrice('5', 42, 250, 'sock')).not.toThrow();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { DaysController } from '../../../src/nest/days/days.controller';
|
||||
import { DayNotesController } from '../../../src/nest/days/day-notes.controller';
|
||||
import { DayReorderError } from '../../../src/services/dayService';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
import type { DayNotesService } from '../../../src/nest/days/day-notes.service';
|
||||
import type { User } from '../../../src/types';
|
||||
@@ -44,6 +45,63 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:created', { day: { id: 9 } }, 'sock');
|
||||
});
|
||||
|
||||
it('POST / 404 when the trip is not accessible', () => {
|
||||
const svc = daysSvc({ verifyTripAccess: vi.fn().mockReturnValue(null) });
|
||||
expect(thrown(() => new DaysController(svc).create(user, '5', {}))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('POST / with a position inserts + broadcasts day:reordered', () => {
|
||||
const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = daysSvc({ insert, create, broadcast } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } });
|
||||
expect(insert).toHaveBeenCalledWith('5', 0);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { day: { id: 12 } }, 'sock');
|
||||
});
|
||||
|
||||
describe('PUT /reorder', () => {
|
||||
it('404 when the trip is not accessible', () => {
|
||||
const svc = daysSvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] }))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('403 without day_edit', () => {
|
||||
const svc = daysSvc({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('400 when orderedIds is missing', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', {}))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
|
||||
});
|
||||
|
||||
it('400 when orderedIds is not an array', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', { orderedIds: 'nope' as never }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
|
||||
});
|
||||
|
||||
it('maps a DayReorderError to 400 with its message', () => {
|
||||
const reorder = vi.fn(() => { throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); });
|
||||
const svc = daysSvc({ reorder } as Partial<DaysService>);
|
||||
expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [9] }))).toEqual({
|
||||
status: 400, body: { error: 'orderedIds must be a permutation of the trip day ids.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows a non-DayReorderError unchanged', () => {
|
||||
const boom = new Error('db is down');
|
||||
const reorder = vi.fn(() => { throw boom; });
|
||||
const svc = daysSvc({ reorder } as Partial<DaysService>);
|
||||
expect(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
|
||||
});
|
||||
|
||||
it('reorders and broadcasts day:reordered', () => {
|
||||
const reorder = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = daysSvc({ reorder, broadcast } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
|
||||
expect(reorder).toHaveBeenCalledWith('5', [2, 1]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { orderedIds: [2, 1] }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id 404 when the day is missing, else updates', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
const update = vi.fn().mockReturnValue({ id: 9, title: 'T' });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { MulterError } from 'multer';
|
||||
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
|
||||
|
||||
function mockHost() {
|
||||
@@ -31,4 +32,89 @@ describe('TrekExceptionFilter', () => {
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
it('maps a multer LIMIT_FILE_SIZE error to 413 with the multer message', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new MulterError('LIMIT_FILE_SIZE', 'avatar'), host);
|
||||
expect(res.status).toHaveBeenCalledWith(413);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'File too large' });
|
||||
});
|
||||
|
||||
it('maps any other multer error to 400 with the multer message', () => {
|
||||
const { res, host } = mockHost();
|
||||
const err = new MulterError('LIMIT_UNEXPECTED_FILE', 'avatar');
|
||||
filter.catch(err, host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: err.message });
|
||||
});
|
||||
|
||||
it('normalises a Nest-shaped { statusCode, message, error } body to { error }', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ statusCode: 400, message: 'Validation failed', error: 'Bad Request' }, 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Validation failed' });
|
||||
});
|
||||
|
||||
it('joins an array message into a single string', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ message: ['too short', 'required'] }, 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'too short, required' });
|
||||
});
|
||||
|
||||
it('falls back to obj.error when an object body has no message', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ statusCode: 400, error: 'Bad Request' }, 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Bad Request' });
|
||||
});
|
||||
|
||||
it("uses 'Error' when an object body carries neither message nor error", () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ statusCode: 400 }, 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Error' });
|
||||
});
|
||||
|
||||
it('hides 5xx object-body details behind Internal server error', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ message: 'secret stack detail' }, 503), host);
|
||||
expect(res.status).toHaveBeenCalledWith(503);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
it('maps a plain error with statusCode to that status (4xx exposes message)', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch({ statusCode: 400, message: 'Only images are allowed' }, host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Only images are allowed' });
|
||||
});
|
||||
|
||||
it('honours a plain error status field when statusCode is absent', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch({ status: 404, message: 'Not here' }, host);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Not here' });
|
||||
});
|
||||
|
||||
it("uses 'Error' for a 4xx plain error with no message", () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch({ statusCode: 422 }, host);
|
||||
expect(res.status).toHaveBeenCalledWith(422);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Error' });
|
||||
});
|
||||
|
||||
it('hides a 5xx string-body HttpException behind Internal server error', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException('database exploded', 500), host);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
it('treats a null exception as a 500', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(null, host);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
@@ -123,6 +126,19 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
const s = fsvc({ getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<FilesService>);
|
||||
expect(new FilesController(s).links(user, '5', '9')).toEqual({ links: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('the trash + link routes all reject without file_delete / file_edit', async () => {
|
||||
const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) });
|
||||
await expect(new FilesController(denied()).permanent(user, '5', '9')).rejects.toMatchObject({ status: 403 });
|
||||
expect(thrown(() => new FilesController(denied()).restore(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new FilesController(denied()).link(user, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new FilesController(denied()).unlink(user, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('GET /:id/links 404 without trip access', () => {
|
||||
const s = fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new FilesController(s).links(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilesDownloadController', () => {
|
||||
@@ -147,6 +163,62 @@ describe('FilesDownloadController', () => {
|
||||
expect(thrown(() => new FilesDownloadController(dsvc({ getFileById: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(thrown(() => new FilesDownloadController(dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: '/x', safe: false }) })).download(req, res, '5', '9'))).toEqual({ status: 403, body: { error: 'Forbidden' } });
|
||||
});
|
||||
|
||||
it('404 when the safe path is gone from disk', () => {
|
||||
const missing = path.join(os.tmpdir(), `trek-no-such-${Date.now()}.pdf`);
|
||||
const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: missing, safe: true }) });
|
||||
expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
});
|
||||
|
||||
it('streams a regular file via sendFile with an explicit root', () => {
|
||||
const real = path.join(os.tmpdir(), `trek-dl-${Date.now()}.pdf`);
|
||||
fs.writeFileSync(real, 'x');
|
||||
try {
|
||||
const sendFile = vi.fn();
|
||||
const localRes = { setHeader: vi.fn(), sendFile } as unknown as Response;
|
||||
const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }) });
|
||||
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
||||
expect(sendFile).toHaveBeenCalledWith(path.basename(real), { root: path.dirname(real) });
|
||||
expect(localRes.setHeader).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.unlinkSync(real);
|
||||
}
|
||||
});
|
||||
|
||||
it('serves a .pkpass inline with the Wallet MIME type and the original name', () => {
|
||||
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`);
|
||||
fs.writeFileSync(real, 'x');
|
||||
try {
|
||||
const setHeader = vi.fn();
|
||||
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
||||
const s = dsvc({
|
||||
getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: 'BoardingPass.pkpass' }),
|
||||
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
||||
});
|
||||
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
||||
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.pkpass');
|
||||
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', 'inline; filename="BoardingPass.pkpass"');
|
||||
} finally {
|
||||
fs.unlinkSync(real);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the resolved basename when a .pkpass has no original name', () => {
|
||||
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`);
|
||||
fs.writeFileSync(real, 'x');
|
||||
try {
|
||||
const setHeader = vi.fn();
|
||||
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
||||
const s = dsvc({
|
||||
getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: null }),
|
||||
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
||||
});
|
||||
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
||||
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', `inline; filename="${path.basename(real)}"`);
|
||||
} finally {
|
||||
fs.unlinkSync(real);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PhotosController', () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request } from 'express';
|
||||
|
||||
// Mock the side-effect dependencies the wrapper reaches into directly.
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { svc } = vi.hoisted(() => ({
|
||||
svc: {
|
||||
verifyTripAccess: vi.fn(),
|
||||
authenticateDownload: vi.fn(),
|
||||
resolveFilePath: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
getFileById: vi.fn(),
|
||||
getDeletedFile: vi.fn(),
|
||||
createFile: vi.fn(),
|
||||
updateFile: vi.fn(),
|
||||
toggleStarred: vi.fn(),
|
||||
softDeleteFile: vi.fn(),
|
||||
restoreFile: vi.fn(),
|
||||
permanentDeleteFile: vi.fn(),
|
||||
emptyTrash: vi.fn(),
|
||||
createFileLink: vi.fn(),
|
||||
deleteFileLink: vi.fn(),
|
||||
getFileLinks: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/fileService', () => svc);
|
||||
|
||||
import { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
function service() {
|
||||
return new FilesService();
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('FilesService (thin wrapper around the legacy fileService)', () => {
|
||||
it('verifyTripAccess delegates to the legacy service', () => {
|
||||
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(service().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(svc.verifyTripAccess).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
|
||||
it('can forwards the ownership flag when the user owns the trip', () => {
|
||||
checkPermission.mockReturnValue(true);
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
expect(service().can('file_edit', { user_id: 1 } as never, user)).toBe(true);
|
||||
expect(checkPermission).toHaveBeenCalledWith('file_edit', 'user', 1, 1, false);
|
||||
});
|
||||
|
||||
it('can marks the user as a guest when they do not own the trip', () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
expect(service().can('file_upload', { user_id: 2 } as never, user)).toBe(false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('file_upload', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('broadcast forwards to the websocket helper', () => {
|
||||
service().broadcast('5', 'file:created', { file: { id: 1 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'file:created', { file: { id: 1 } }, 'sock');
|
||||
});
|
||||
|
||||
it('authenticateDownload / resolveFilePath delegate', () => {
|
||||
const req = { headers: {} } as Request;
|
||||
svc.authenticateDownload.mockReturnValue({ userId: 7 });
|
||||
expect(service().authenticateDownload(req)).toEqual({ userId: 7 });
|
||||
expect(svc.authenticateDownload).toHaveBeenCalledWith(req);
|
||||
|
||||
svc.resolveFilePath.mockReturnValue({ resolved: '/a/b.pdf', safe: true });
|
||||
expect(service().resolveFilePath('b.pdf')).toEqual({ resolved: '/a/b.pdf', safe: true });
|
||||
expect(svc.resolveFilePath).toHaveBeenCalledWith('b.pdf');
|
||||
});
|
||||
|
||||
it('the read helpers delegate', () => {
|
||||
svc.listFiles.mockReturnValue([{ id: 1 }]);
|
||||
expect(service().listFiles('5', true)).toEqual([{ id: 1 }]);
|
||||
expect(svc.listFiles).toHaveBeenCalledWith('5', true);
|
||||
|
||||
svc.getFileById.mockReturnValue({ id: 9 });
|
||||
expect(service().getFileById('9', '5')).toEqual({ id: 9 });
|
||||
expect(svc.getFileById).toHaveBeenCalledWith('9', '5');
|
||||
|
||||
svc.getDeletedFile.mockReturnValue({ id: 9 });
|
||||
expect(service().getDeletedFile('9', '5')).toEqual({ id: 9 });
|
||||
expect(svc.getDeletedFile).toHaveBeenCalledWith('9', '5');
|
||||
|
||||
svc.getFileLinks.mockReturnValue([{ id: 1 }]);
|
||||
expect(service().getFileLinks('9')).toEqual([{ id: 1 }]);
|
||||
expect(svc.getFileLinks).toHaveBeenCalledWith('9');
|
||||
});
|
||||
|
||||
it('the mutating helpers delegate', () => {
|
||||
const file = { filename: 'a.pdf' } as Express.Multer.File;
|
||||
svc.createFile.mockReturnValue({ id: 9 });
|
||||
expect(service().createFile('5', file, 1, { description: 'd' })).toEqual({ id: 9 });
|
||||
expect(svc.createFile).toHaveBeenCalledWith('5', file, 1, { description: 'd' });
|
||||
|
||||
svc.updateFile.mockReturnValue({ id: 9 });
|
||||
const current = { id: 9 } as never;
|
||||
expect(service().updateFile('9', current, { description: 'x' })).toEqual({ id: 9 });
|
||||
expect(svc.updateFile).toHaveBeenCalledWith('9', current, { description: 'x' });
|
||||
|
||||
svc.toggleStarred.mockReturnValue({ id: 9, starred: 1 });
|
||||
expect(service().toggleStarred('9', 0)).toEqual({ id: 9, starred: 1 });
|
||||
expect(svc.toggleStarred).toHaveBeenCalledWith('9', 0);
|
||||
|
||||
service().softDeleteFile('9');
|
||||
expect(svc.softDeleteFile).toHaveBeenCalledWith('9');
|
||||
|
||||
svc.restoreFile.mockReturnValue({ id: 9 });
|
||||
expect(service().restoreFile('9')).toEqual({ id: 9 });
|
||||
expect(svc.restoreFile).toHaveBeenCalledWith('9');
|
||||
|
||||
const trashed = { id: 9 } as never;
|
||||
service().permanentDeleteFile(trashed);
|
||||
expect(svc.permanentDeleteFile).toHaveBeenCalledWith(trashed);
|
||||
|
||||
svc.emptyTrash.mockReturnValue(3);
|
||||
expect(service().emptyTrash('5')).toBe(3);
|
||||
expect(svc.emptyTrash).toHaveBeenCalledWith('5');
|
||||
|
||||
svc.createFileLink.mockReturnValue([{ id: 1 }]);
|
||||
expect(service().createFileLink('9', { reservation_id: 2 })).toEqual([{ id: 1 }]);
|
||||
expect(svc.createFileLink).toHaveBeenCalledWith('9', { reservation_id: 2 });
|
||||
|
||||
service().deleteFileLink('3', '9');
|
||||
expect(svc.deleteFileLink).toHaveBeenCalledWith('3', '9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||
import { HealthService } from '../../../src/nest/health/health.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function makeService(overrides: Partial<HealthService> = {}): HealthService {
|
||||
return {
|
||||
info: vi.fn().mockReturnValue({ runtime: 'nestjs', diInjected: true, userCount: 0 }),
|
||||
...overrides,
|
||||
} as unknown as HealthService;
|
||||
}
|
||||
|
||||
describe('HealthController (foundation smoke endpoints under /api/_nest)', () => {
|
||||
it('GET /health merges ok:true with the service info', () => {
|
||||
const svc = makeService({
|
||||
info: vi.fn().mockReturnValue({ runtime: 'nestjs', diInjected: true, userCount: 7 }),
|
||||
});
|
||||
expect(new HealthController(svc).getHealth()).toEqual({
|
||||
ok: true,
|
||||
runtime: 'nestjs',
|
||||
diInjected: true,
|
||||
userCount: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /me returns the authenticated user as-is', () => {
|
||||
const svc = makeService();
|
||||
expect(new HealthController(svc).me(user)).toBe(user);
|
||||
});
|
||||
|
||||
it('POST /echo wraps the validated body', () => {
|
||||
const svc = makeService();
|
||||
expect(new HealthController(svc).echo({ name: 'Maurice' })).toEqual({
|
||||
youSent: { name: 'Maurice' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthService.info (shared SQLite connection proof)', () => {
|
||||
function makeDb(get: () => unknown): DatabaseService {
|
||||
return { get: vi.fn(get) } as unknown as DatabaseService;
|
||||
}
|
||||
|
||||
it('returns the real user count when the row resolves', () => {
|
||||
const service = new HealthService(makeDb(() => ({ n: 42 })));
|
||||
expect(service.info()).toEqual({
|
||||
runtime: 'nestjs',
|
||||
diInjected: true,
|
||||
userCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to null when the row is undefined', () => {
|
||||
const service = new HealthService(makeDb(() => undefined));
|
||||
expect(service.info().userCount).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to null when the count column is null', () => {
|
||||
const service = new HealthService(makeDb(() => ({ n: null })));
|
||||
expect(service.info().userCount).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -144,4 +144,54 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
|
||||
res.json({ error: 'bad' });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not cache a body that exceeds the 256 KiB cap', async () => {
|
||||
const run = vi.fn();
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
|
||||
const res = makeRes();
|
||||
const big = { blob: 'x'.repeat(300 * 1024) };
|
||||
const h = handler(big);
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
res.statusCode = 200;
|
||||
res.json(big);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a storage failure so the response still succeeds', async () => {
|
||||
const run = vi.fn(() => {
|
||||
throw new Error('db is locked');
|
||||
});
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
|
||||
const res = makeRes();
|
||||
const h = handler({ ok: true });
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
res.statusCode = 201;
|
||||
const returned = res.json({ ok: true });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(returned).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('treats a PATCH as a mutating method', async () => {
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() });
|
||||
const res = makeRes();
|
||||
const h = handler('done');
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'PATCH', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories/1', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
expect(db.get).toHaveBeenCalled();
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,8 @@ describe('JourneyController', () => {
|
||||
const c = new JourneyController(svc({ linkPhotoToEntry } as Partial<JourneyService>));
|
||||
expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
|
||||
expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1);
|
||||
// accepts the canonical journey_photo_id, 403 when the service refuses
|
||||
expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('unlink photo (204) maps 404; delete photo 404 then unlinks file', () => {
|
||||
@@ -143,6 +145,113 @@ describe('JourneyController', () => {
|
||||
await new JourneyController(noOptIn).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
|
||||
expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above
|
||||
});
|
||||
|
||||
it('entry photo upload: 400 no files, 403 when nothing added, swallows immich errors and empty ids', async () => {
|
||||
expect(await thrownAsync(() => new JourneyController(svc()).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
|
||||
expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
|
||||
// opted in but the immich upload throws → best-effort, the local photo still wins
|
||||
const setPhotoProvider = vi.fn();
|
||||
const blowsUp = svc({ addPhoto: vi.fn().mockReturnValue({ id: 8 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockRejectedValue(new Error('immich down')), setPhotoProvider } as Partial<JourneyService>);
|
||||
expect(await new JourneyController(blowsUp).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
|
||||
expect(setPhotoProvider).not.toHaveBeenCalled();
|
||||
|
||||
// opted in but immich returns a falsy id → no provider stamping
|
||||
const noId = svc({ addPhoto: vi.fn().mockReturnValue({ id: 9 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockResolvedValue(''), setPhotoProvider } as Partial<JourneyService>);
|
||||
expect(await new JourneyController(noId).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
|
||||
});
|
||||
|
||||
it('provider-photos batch passes the passphrase through when present', () => {
|
||||
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
|
||||
new JourneyController(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
|
||||
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret');
|
||||
// single-photo success path
|
||||
expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
|
||||
});
|
||||
|
||||
it('PATCH photos: 404 then returns the updated photo', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
|
||||
expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
|
||||
});
|
||||
|
||||
it('DELETE photo unlinks the file when a path exists', () => {
|
||||
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined);
|
||||
try {
|
||||
expect(new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
// a vanished file is swallowed
|
||||
unlinkSpy.mockImplementationOnce(() => { throw new Error('ENOENT'); });
|
||||
expect(new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '8')).toEqual({ success: true });
|
||||
} finally {
|
||||
unlinkSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => {
|
||||
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
|
||||
const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial<JourneyService>));
|
||||
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
|
||||
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw');
|
||||
expect(thrown(() => new JourneyController(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
|
||||
expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
|
||||
});
|
||||
|
||||
it('DELETE gallery photo: 404, then unlinks the file when present', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
|
||||
// no file_path → nothing to unlink, returns void
|
||||
expect(new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '7')).toBeUndefined();
|
||||
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined);
|
||||
try {
|
||||
new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '8');
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
unlinkSpy.mockImplementationOnce(() => { throw new Error('ENOENT'); });
|
||||
expect(new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '9')).toBeUndefined();
|
||||
} finally {
|
||||
unlinkSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('PATCH /:id returns the updated journey on success', () => {
|
||||
expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
|
||||
});
|
||||
|
||||
it('cover upload: 400 without file, 404 when the journey is gone, else returns the journey', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
const updateJourney = vi.fn().mockReturnValue({ id: 9, cover_image: 'journey/c.jpg' });
|
||||
expect(new JourneyController(svc({ updateJourney } as Partial<JourneyService>)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' });
|
||||
expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' });
|
||||
});
|
||||
|
||||
it('DELETE /:id and trips/contributors success paths', () => {
|
||||
expect(new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).remove(user, '9')).toEqual({ success: true });
|
||||
expect(new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeTrip(user, '9', '2')).toEqual({ success: true });
|
||||
expect(new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
|
||||
expect(new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeContributor(user, '9', '2')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('addContributor defaults the role to viewer when omitted', () => {
|
||||
const addContributor = vi.fn().mockReturnValue(true);
|
||||
new JourneyController(svc({ addContributor } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 });
|
||||
expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer');
|
||||
});
|
||||
|
||||
it('createEntry returns the entry when the journey exists', () => {
|
||||
expect(new JourneyController(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
|
||||
});
|
||||
|
||||
it('reorderEntries succeeds for a numeric array', () => {
|
||||
expect(new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('preferences returns the result on success', () => {
|
||||
expect(new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('deleteShareLink returns success when removed', () => {
|
||||
expect(new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteShareLink(user, '9')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyPublicController', () => {
|
||||
@@ -167,6 +276,45 @@ describe('JourneyPublicController', () => {
|
||||
expect(streamImmichAsset).toHaveBeenCalledWith({}, 5, 'a1', 'original', 5);
|
||||
});
|
||||
|
||||
it('photo proxy streams thumbnails too', async () => {
|
||||
const streamPhoto = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue({ ownerId: 3 }), streamPhoto } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).photo('tok', '7', 'thumbnail', {} as Response);
|
||||
expect(streamPhoto).toHaveBeenCalledWith({}, 3, 7, 'thumbnail');
|
||||
});
|
||||
|
||||
it('legacy photo proxy: synology streams, and a failure becomes a 404 json', async () => {
|
||||
const streamSynologyAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }), streamSynologyAsset } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).legacyPhoto('tok', 'synology', 'a1', '2', 'thumbnail', {} as Response);
|
||||
expect(streamSynologyAsset).toHaveBeenCalledWith({}, 5, 5, 'a1', 'thumbnail');
|
||||
|
||||
const status = vi.fn().mockReturnThis();
|
||||
const json = vi.fn();
|
||||
const res = { status, json } as unknown as Response;
|
||||
const failing = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 0 }), streamSynologyAsset: vi.fn().mockRejectedValue(new Error('no synology')) } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(failing).legacyPhoto('tok', 'synology', 'a1', '6', 'original', res);
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Provider not supported' });
|
||||
});
|
||||
|
||||
it('legacy photo proxy: falls back to the path ownerId when the token has none', async () => {
|
||||
const streamImmichAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 0 }), streamImmichAsset } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).legacyPhoto('tok', 'immich', 'a1', '8', 'original', {} as Response);
|
||||
expect(streamImmichAsset).toHaveBeenCalledWith({}, 8, 'a1', 'original', 8);
|
||||
});
|
||||
|
||||
it('legacy photo proxy: local provider 404s when the resolved file does not exist', async () => {
|
||||
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
try {
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial<JourneyService>);
|
||||
expect(await thrownAsync(() => new JourneyPublicController(s).legacyPhoto('tok', 'local', 'gone.jpg', '2', 'thumbnail', {} as Response))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
} finally {
|
||||
existsSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('legacy photo proxy: local provider cannot escape uploads/journey via a traversal asset id', async () => {
|
||||
// Pretend any path exists so we can inspect exactly what would be served.
|
||||
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
|
||||
@@ -50,12 +50,67 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined);
|
||||
});
|
||||
|
||||
it('400 on a malformed locationBias (non-finite lat/lng)', async () => {
|
||||
const search = vi.fn();
|
||||
const bad = { lat: NaN, lng: 2 };
|
||||
expect(await thrown(() => makeController({ search }).search(user, 'x', 'de', bad))).toEqual({
|
||||
status: 400, body: { error: 'Invalid locationBias: lat and lng must be finite numbers' },
|
||||
});
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards a valid locationBias to the service', async () => {
|
||||
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
|
||||
const bias = { lat: 1, lng: 2, radius: 5000 };
|
||||
await makeController({ search }).search(user, 'x', 'de', bias);
|
||||
expect(search).toHaveBeenCalledWith(3, 'x', 'de', bias);
|
||||
});
|
||||
|
||||
it('maps a service error to its status + message', async () => {
|
||||
const search = vi.fn().mockRejectedValue(withError(429, 'Rate limited'));
|
||||
expect(await thrown(() => makeController({ search }).search(user, 'x'))).toEqual({
|
||||
status: 429, body: { error: 'Rate limited' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults a non-Error rejection to 500 + the fallback message', async () => {
|
||||
const search = vi.fn().mockRejectedValue('boom');
|
||||
expect(await thrown(() => makeController({ search }).search(user, 'x'))).toEqual({
|
||||
status: 500, body: { error: 'Search error' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /pois', () => {
|
||||
it('400 when category is missing', async () => {
|
||||
const pois = vi.fn();
|
||||
expect(await thrown(() => makeController({ pois }).pois(undefined, '1', '2', '3', '4'))).toEqual({
|
||||
status: 400, body: { error: 'A category is required' },
|
||||
});
|
||||
expect(pois).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 when the bbox has a non-finite value', async () => {
|
||||
const pois = vi.fn();
|
||||
expect(await thrown(() => makeController({ pois }).pois('cafe', 'x', '2', '3', '4'))).toEqual({
|
||||
status: 400, body: { error: 'A valid bbox (south, west, north, east) is required' },
|
||||
});
|
||||
expect(pois).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates a valid request with a parsed numeric bbox', async () => {
|
||||
const pois = vi.fn().mockResolvedValue({ places: [] });
|
||||
const res = await makeController({ pois }).pois('cafe', '1', '2', '3', '4');
|
||||
expect(res).toEqual({ places: [] });
|
||||
expect(pois).toHaveBeenCalledWith('cafe', { south: 1, west: 2, north: 3, east: 4 });
|
||||
});
|
||||
|
||||
it('maps a service error, defaulting to 500', async () => {
|
||||
const pois = vi.fn().mockRejectedValue(new Error('Overpass down'));
|
||||
expect(await thrown(() => makeController({ pois }).pois('cafe', '1', '2', '3', '4'))).toEqual({
|
||||
status: 500, body: { error: 'Overpass down' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /autocomplete', () => {
|
||||
@@ -87,12 +142,28 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('400 when locationBias is missing the high corner', async () => {
|
||||
const c = makeController({ autocompleteDisabled: () => false });
|
||||
const bad = { low: { lat: 1, lng: 2 } } as never;
|
||||
expect(await thrown(() => c.autocomplete(user, 'be', undefined, bad))).toEqual({
|
||||
status: 400, body: { error: 'Invalid locationBias: low and high must have finite lat and lng' },
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates a valid request', async () => {
|
||||
const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'osm' });
|
||||
const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
||||
await makeController({ autocompleteDisabled: () => false, autocomplete }).autocomplete(user, 'be', 'en', bias);
|
||||
expect(autocomplete).toHaveBeenCalledWith(3, 'be', 'en', bias);
|
||||
});
|
||||
|
||||
it('maps a service error', async () => {
|
||||
const autocomplete = vi.fn().mockRejectedValue(withError(503, 'Upstream down'));
|
||||
const c = makeController({ autocompleteDisabled: () => false, autocomplete });
|
||||
expect(await thrown(() => c.autocomplete(user, 'be'))).toEqual({
|
||||
status: 503, body: { error: 'Upstream down' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /details/:placeId', () => {
|
||||
@@ -138,12 +209,30 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
expect(photo).toHaveBeenCalledWith(3, 'coords:1,2', 1, 2, 'Spot');
|
||||
});
|
||||
|
||||
it('maps a service error', async () => {
|
||||
it('maps a 4xx service error', async () => {
|
||||
const photo = vi.fn().mockRejectedValue(withError(404, 'No photo available'));
|
||||
expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({
|
||||
status: 404, body: { error: 'No photo available' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logs and maps a 5xx service error', async () => {
|
||||
const photo = vi.fn().mockRejectedValue(withError(502, 'Upstream failed'));
|
||||
expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({
|
||||
status: 502, body: { error: 'Upstream failed' },
|
||||
});
|
||||
expect(console.error).toHaveBeenCalledWith('Place photo error:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('defaults a status-less error to 500 and parses NaN coords', async () => {
|
||||
const photo = vi.fn().mockRejectedValue(new Error('Error fetching photo'));
|
||||
expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1'))).toEqual({
|
||||
status: 500, body: { error: 'Error fetching photo' },
|
||||
});
|
||||
const [, , lat, lng] = photo.mock.calls[0];
|
||||
expect(Number.isNaN(lat)).toBe(true);
|
||||
expect(Number.isNaN(lng)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /place-photo/:placeId/bytes', () => {
|
||||
@@ -190,6 +279,18 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
|
||||
});
|
||||
|
||||
it('does not re-send a 404 when the stream errors after headers were flushed', () => {
|
||||
let onError: () => void = () => {};
|
||||
const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = makeRes();
|
||||
(res as { headersSent: boolean }).headersSent = true;
|
||||
makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res);
|
||||
onError();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /reverse', () => {
|
||||
@@ -220,11 +321,39 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
expect(await makeController({ resolveUrl }).resolveUrl('https://maps.app.goo.gl/x')).toEqual({ lat: 1, lng: 2, name: null, address: null });
|
||||
});
|
||||
|
||||
it('400 when url is not a string', async () => {
|
||||
expect(await thrown(() => makeController({}).resolveUrl(42 as unknown as string))).toEqual({
|
||||
status: 400, body: { error: 'URL is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a service error, defaulting to 400', async () => {
|
||||
const resolveUrl = vi.fn().mockRejectedValue(new Error('Failed to resolve URL'));
|
||||
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to resolve URL' },
|
||||
});
|
||||
});
|
||||
|
||||
it('honours an explicit status on the thrown error', async () => {
|
||||
const resolveUrl = vi.fn().mockRejectedValue(withError(422, 'Unsupported link'));
|
||||
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({
|
||||
status: 422, body: { error: 'Unsupported link' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default message when a non-Error is thrown', async () => {
|
||||
const resolveUrl = vi.fn().mockRejectedValue('nope');
|
||||
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to resolve URL' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /reverse', () => {
|
||||
it('forwards lang through to the service', async () => {
|
||||
const reverse = vi.fn().mockResolvedValue({ name: null, address: null });
|
||||
await makeController({ reverse }).reverse('1', '2', 'fr');
|
||||
expect(reverse).toHaveBeenCalledWith('1', '2', 'fr');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { maps } = vi.hoisted(() => ({
|
||||
maps: {
|
||||
searchPlaces: vi.fn(),
|
||||
autocompletePlaces: vi.fn(),
|
||||
getPlaceDetails: vi.fn(),
|
||||
getPlaceDetailsExpanded: vi.fn(),
|
||||
getPlacePhoto: vi.fn(),
|
||||
reverseGeocode: vi.fn(),
|
||||
resolveGoogleMapsUrl: vi.fn(),
|
||||
searchOverpassPois: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/mapsService', () => maps);
|
||||
|
||||
const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() }));
|
||||
vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
/** A DatabaseService stub whose get() returns the row the test wants. */
|
||||
function makeDb(row?: { value: string }) {
|
||||
const get = vi.fn(() => row);
|
||||
const db = { get } as unknown as DatabaseService;
|
||||
return { db, get };
|
||||
}
|
||||
|
||||
function svc(row?: { value: string }) {
|
||||
return new MapsService(makeDb(row).db);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('MapsService', () => {
|
||||
describe('kill-switch settings reads', () => {
|
||||
it('reports a switch disabled when the stored value is exactly "false"', () => {
|
||||
expect(svc({ value: 'false' }).autocompleteDisabled()).toBe(true);
|
||||
expect(svc({ value: 'false' }).detailsDisabled()).toBe(true);
|
||||
expect(svc({ value: 'false' }).photosDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports enabled when the value is "true"', () => {
|
||||
expect(svc({ value: 'true' }).autocompleteDisabled()).toBe(false);
|
||||
expect(svc({ value: 'true' }).detailsDisabled()).toBe(false);
|
||||
expect(svc({ value: 'true' }).photosDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports enabled when the setting row is absent', () => {
|
||||
expect(svc(undefined).autocompleteDisabled()).toBe(false);
|
||||
expect(svc(undefined).detailsDisabled()).toBe(false);
|
||||
expect(svc(undefined).photosDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('queries the matching app_settings key', () => {
|
||||
const { db, get } = makeDb({ value: 'true' });
|
||||
const s = new MapsService(db);
|
||||
s.autocompleteDisabled();
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('app_settings'), 'places_autocomplete_enabled');
|
||||
s.detailsDisabled();
|
||||
expect(get).toHaveBeenCalledWith(expect.any(String), 'places_details_enabled');
|
||||
s.photosDisabled();
|
||||
expect(get).toHaveBeenCalledWith(expect.any(String), 'places_photos_enabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delegation to the legacy maps service', () => {
|
||||
it('search forwards userId, query, lang and bias', () => {
|
||||
maps.searchPlaces.mockResolvedValue({ places: [], source: 'osm' });
|
||||
const bias = { lat: 1, lng: 2, radius: 5 };
|
||||
svc().search(3, 'berlin', 'de', bias);
|
||||
expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', bias);
|
||||
});
|
||||
|
||||
it('search works without optional args', () => {
|
||||
svc().search(3, 'berlin');
|
||||
expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', undefined, undefined);
|
||||
});
|
||||
|
||||
it('autocomplete forwards through', () => {
|
||||
const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
||||
svc().autocomplete(3, 'be', 'en', bias);
|
||||
expect(maps.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', bias);
|
||||
});
|
||||
|
||||
it('details forwards through', () => {
|
||||
svc().details(3, 'p1', 'de');
|
||||
expect(maps.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de');
|
||||
});
|
||||
|
||||
it('detailsExpanded forwards refresh through', () => {
|
||||
svc().detailsExpanded(3, 'p1', 'de', true);
|
||||
expect(maps.getPlaceDetailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true);
|
||||
});
|
||||
|
||||
it('photo forwards coords and name through', () => {
|
||||
svc().photo(3, 'p1', 1.5, 2.5, 'Spot');
|
||||
expect(maps.getPlacePhoto).toHaveBeenCalledWith(3, 'p1', 1.5, 2.5, 'Spot');
|
||||
});
|
||||
|
||||
it('reverse forwards through', () => {
|
||||
svc().reverse('1', '2', 'de');
|
||||
expect(maps.reverseGeocode).toHaveBeenCalledWith('1', '2', 'de');
|
||||
});
|
||||
|
||||
it('resolveUrl forwards through', () => {
|
||||
svc().resolveUrl('https://maps.app.goo.gl/x');
|
||||
expect(maps.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x');
|
||||
});
|
||||
|
||||
it('pois forwards category and bbox through', () => {
|
||||
const bbox = { south: 1, west: 2, north: 3, east: 4 };
|
||||
svc().pois('cafe', bbox);
|
||||
expect(maps.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox);
|
||||
});
|
||||
});
|
||||
|
||||
describe('photoBytesPath', () => {
|
||||
it('returns the cached file path from placePhotoCache', () => {
|
||||
serveFilePath.mockReturnValue('/cache/p1.jpg');
|
||||
expect(svc().photoBytesPath('p1')).toBe('/cache/p1.jpg');
|
||||
expect(serveFilePath).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('returns null when nothing is cached', () => {
|
||||
serveFilePath.mockReturnValue(null);
|
||||
expect(svc().photoBytesPath('p1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,748 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Response } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import { UnifiedMemoriesController } from '../../../src/nest/memories/unified.controller';
|
||||
import { ImmichMemoriesController } from '../../../src/nest/memories/immich.controller';
|
||||
import { SynologyMemoriesController } from '../../../src/nest/memories/synology.controller';
|
||||
import type { MemoriesService } from '../../../src/nest/memories/memories.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const { getClientIp } = vi.hoisted(() => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../../src/services/auditLog', () => ({ getClientIp }));
|
||||
|
||||
const user = { id: 7, role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function makeService(overrides: Partial<MemoriesService> = {}): MemoriesService {
|
||||
return { ...overrides } as unknown as MemoriesService;
|
||||
}
|
||||
|
||||
type MockRes = Response & {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
statusCode: number;
|
||||
};
|
||||
|
||||
function makeRes(): MockRes {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status: vi.fn(function (this: unknown, c: number) {
|
||||
(res as { statusCode: number }).statusCode = c;
|
||||
return res;
|
||||
}),
|
||||
json: vi.fn(function () {
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return res as unknown as MockRes;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('UnifiedMemoriesController (parity with /api/integrations/memories/unified)', () => {
|
||||
describe('GET /trips/:tripId/photos', () => {
|
||||
it('returns the photos on success', () => {
|
||||
const svc = makeService({ listTripPhotos: vi.fn().mockReturnValue({ data: [{ id: 1 }] }) });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).listPhotos(user, '5', res);
|
||||
expect(svc.listTripPhotos).toHaveBeenCalledWith('5', 7);
|
||||
expect(res.json).toHaveBeenCalledWith({ photos: [{ id: 1 }] });
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps the error envelope to its status + message', () => {
|
||||
const svc = makeService({ listTripPhotos: vi.fn().mockReturnValue({ error: { status: 404, message: 'Trip not found' } }) });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).listPhotos(user, '5', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Trip not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trips/:tripId/photos', () => {
|
||||
it('defaults shared to true and selections to [] when both are absent', async () => {
|
||||
const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 3 } });
|
||||
const svc = makeService({ addTripPhotos });
|
||||
const res = makeRes();
|
||||
await new UnifiedMemoriesController(svc).addPhotos(user, '5', {}, 'sock', res);
|
||||
expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock');
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true, added: 3 });
|
||||
});
|
||||
|
||||
it('coerces a falsy shared flag and forwards an array of selections', async () => {
|
||||
const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } });
|
||||
const svc = makeService({ addTripPhotos });
|
||||
const selections = [{ provider: 'immich', asset_ids: ['a'] }];
|
||||
await new UnifiedMemoriesController(svc).addPhotos(user, '5', { shared: 0, selections }, 'sock', makeRes());
|
||||
expect(addTripPhotos).toHaveBeenCalledWith('5', 7, false, selections, 'sock');
|
||||
});
|
||||
|
||||
it('ignores a non-array selections payload', async () => {
|
||||
const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } });
|
||||
const svc = makeService({ addTripPhotos });
|
||||
await new UnifiedMemoriesController(svc).addPhotos(user, '5', { selections: 'nope', shared: true }, 'sock', makeRes());
|
||||
expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock');
|
||||
});
|
||||
|
||||
it('maps the error envelope', async () => {
|
||||
const svc = makeService({ addTripPhotos: vi.fn().mockResolvedValue({ error: { status: 403, message: 'No access' } }) });
|
||||
const res = makeRes();
|
||||
await new UnifiedMemoriesController(svc).addPhotos(user, '5', {}, 'sock', res);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'No access' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /trips/:tripId/photos/sharing', () => {
|
||||
it('coerces photo_id to a number and forwards shared', async () => {
|
||||
const setTripPhotoSharing = vi.fn().mockResolvedValue({ data: {} });
|
||||
const svc = makeService({ setTripPhotoSharing });
|
||||
const res = makeRes();
|
||||
await new UnifiedMemoriesController(svc).setSharing(user, '5', { photo_id: '9', shared: true }, res);
|
||||
expect(setTripPhotoSharing).toHaveBeenCalledWith('5', 7, 9, true);
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('maps the error envelope', async () => {
|
||||
const svc = makeService({ setTripPhotoSharing: vi.fn().mockResolvedValue({ error: { status: 404, message: 'Photo not found' } }) });
|
||||
const res = makeRes();
|
||||
await new UnifiedMemoriesController(svc).setSharing(user, '5', { photo_id: '9', shared: false }, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /trips/:tripId/photos', () => {
|
||||
it('removes the photo on success', () => {
|
||||
const removeTripPhoto = vi.fn().mockReturnValue({ data: {} });
|
||||
const svc = makeService({ removeTripPhoto });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).removePhoto(user, '5', { photo_id: 11 }, res);
|
||||
expect(removeTripPhoto).toHaveBeenCalledWith('5', 7, 11);
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('maps the error envelope', () => {
|
||||
const svc = makeService({ removeTripPhoto: vi.fn().mockReturnValue({ error: { status: 404, message: 'Photo not found' } }) });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).removePhoto(user, '5', { photo_id: 11 }, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /trips/:tripId/album-links', () => {
|
||||
it('returns the links on success', () => {
|
||||
const svc = makeService({ listTripAlbumLinks: vi.fn().mockReturnValue({ data: [{ id: 'l1' }] }) });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res);
|
||||
expect(res.json).toHaveBeenCalledWith({ links: [{ id: 'l1' }] });
|
||||
});
|
||||
|
||||
it('maps the error envelope', () => {
|
||||
const svc = makeService({ listTripAlbumLinks: vi.fn().mockReturnValue({ error: { status: 404, message: 'Trip not found' } }) });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Trip not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trips/:tripId/album-links', () => {
|
||||
it('forwards a coerced passphrase when present', () => {
|
||||
const createTripAlbumLink = vi.fn().mockReturnValue({ data: {} });
|
||||
const svc = makeService({ createTripAlbumLink });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).createAlbumLink(
|
||||
user,
|
||||
'5',
|
||||
{ provider: 'synologyphotos', album_id: 'a1', album_name: 'Trip', passphrase: 123 },
|
||||
res,
|
||||
);
|
||||
expect(createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'synologyphotos', 'a1', 'Trip', '123');
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('passes undefined when the passphrase is absent or empty', () => {
|
||||
const createTripAlbumLink = vi.fn().mockReturnValue({ data: {} });
|
||||
const svc = makeService({ createTripAlbumLink });
|
||||
new UnifiedMemoriesController(svc).createAlbumLink(user, '5', { provider: 'immich', album_id: 'a1', album_name: 'Trip', passphrase: '' }, makeRes());
|
||||
expect(createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'immich', 'a1', 'Trip', undefined);
|
||||
});
|
||||
|
||||
it('maps the error envelope', () => {
|
||||
const svc = makeService({ createTripAlbumLink: vi.fn().mockReturnValue({ error: { status: 400, message: 'Invalid provider' } }) });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).createAlbumLink(user, '5', {}, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid provider' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /trips/:tripId/album-links/:linkId', () => {
|
||||
it('removes the link on success', () => {
|
||||
const removeAlbumLink = vi.fn().mockReturnValue({ data: {} });
|
||||
const svc = makeService({ removeAlbumLink });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
|
||||
expect(removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7);
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('maps the error envelope', () => {
|
||||
const svc = makeService({ removeAlbumLink: vi.fn().mockReturnValue({ error: { status: 404, message: 'Link not found' } }) });
|
||||
const res = makeRes();
|
||||
new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Link not found' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('ImmichMemoriesController (parity with /api/integrations/memories/immich)', () => {
|
||||
describe('GET /settings', () => {
|
||||
it('delegates to the service', () => {
|
||||
const immichGetConnectionSettings = vi.fn().mockReturnValue({ immich_url: 'u' });
|
||||
const svc = makeService({ immichGetConnectionSettings });
|
||||
expect(new ImmichMemoriesController(svc).getSettings(user)).toEqual({ immich_url: 'u' });
|
||||
expect(immichGetConnectionSettings).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /settings', () => {
|
||||
const req = {} as Request;
|
||||
|
||||
it('400 when the save fails', async () => {
|
||||
const svc = makeService({ immichSaveSettings: vi.fn().mockResolvedValue({ success: false, error: 'Bad URL' }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k' }, req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Bad URL' });
|
||||
});
|
||||
|
||||
it('applies auto_upload when it is a boolean and returns success', async () => {
|
||||
const immichSaveSettings = vi.fn().mockResolvedValue({ success: true });
|
||||
const immichSetAutoUpload = vi.fn();
|
||||
const svc = makeService({ immichSaveSettings, immichSetAutoUpload });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k', auto_upload: true }, req, res);
|
||||
expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4');
|
||||
expect(immichSetAutoUpload).toHaveBeenCalledWith(7, true);
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('skips auto_upload when it is not a boolean', async () => {
|
||||
const immichSaveSettings = vi.fn().mockResolvedValue({ success: true });
|
||||
const immichSetAutoUpload = vi.fn();
|
||||
const svc = makeService({ immichSaveSettings, immichSetAutoUpload });
|
||||
await new ImmichMemoriesController(svc).putSettings(user, { auto_upload: 'yes' as unknown as boolean }, req, makeRes());
|
||||
expect(immichSetAutoUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the warning when the save carries one', async () => {
|
||||
const svc = makeService({ immichSaveSettings: vi.fn().mockResolvedValue({ success: true, warning: 'Unverified TLS' }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).putSettings(user, {}, req, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true, warning: 'Unverified TLS' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', () => {
|
||||
it('delegates to the service', async () => {
|
||||
const svc = makeService({ immichGetConnectionStatus: vi.fn().mockResolvedValue({ connected: true }) });
|
||||
await expect(new ImmichMemoriesController(svc).getStatus(user)).resolves.toEqual({ connected: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /test', () => {
|
||||
it('short-circuits to a 200 envelope when url is missing', async () => {
|
||||
const immichTestConnection = vi.fn();
|
||||
const svc = makeService({ immichTestConnection });
|
||||
expect(await new ImmichMemoriesController(svc).test({ immich_api_key: 'k' })).toEqual({ connected: false, error: 'URL and API key required' });
|
||||
expect(immichTestConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('short-circuits when the api key is missing', async () => {
|
||||
const immichTestConnection = vi.fn();
|
||||
const svc = makeService({ immichTestConnection });
|
||||
expect(await new ImmichMemoriesController(svc).test({ immich_url: 'u' })).toEqual({ connected: false, error: 'URL and API key required' });
|
||||
expect(immichTestConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates when both are present', async () => {
|
||||
const immichTestConnection = vi.fn().mockResolvedValue({ connected: true });
|
||||
const svc = makeService({ immichTestConnection });
|
||||
expect(await new ImmichMemoriesController(svc).test({ immich_url: 'u', immich_api_key: 'k' })).toEqual({ connected: true });
|
||||
expect(immichTestConnection).toHaveBeenCalledWith('u', 'k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /browse', () => {
|
||||
it('returns the buckets on success', async () => {
|
||||
const svc = makeService({ immichBrowseTimeline: vi.fn().mockResolvedValue({ buckets: [{ id: 'b' }] }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).browse(user, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ buckets: [{ id: 'b' }] });
|
||||
});
|
||||
|
||||
it('maps the error with its status', async () => {
|
||||
const svc = makeService({ immichBrowseTimeline: vi.fn().mockResolvedValue({ error: 'Not connected', status: 412 }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).browse(user, res);
|
||||
expect(res.status).toHaveBeenCalledWith(412);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Not connected' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /search', () => {
|
||||
it('clamps page to >=1 and size to <=200 and defaults both', async () => {
|
||||
const immichSearchPhotos = vi.fn().mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true });
|
||||
const svc = makeService({ immichSearchPhotos });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).search(user, { from: 'f', to: 't' }, res);
|
||||
expect(immichSearchPhotos).toHaveBeenCalledWith(7, 'f', 't', 1, 50);
|
||||
expect(res.json).toHaveBeenCalledWith({ assets: [{ id: 'a' }], hasMore: true });
|
||||
});
|
||||
|
||||
it('floors a sub-1 page to 1 and caps an oversized size at 200', async () => {
|
||||
const immichSearchPhotos = vi.fn().mockResolvedValue({});
|
||||
const svc = makeService({ immichSearchPhotos });
|
||||
await new ImmichMemoriesController(svc).search(user, { page: 0, size: 9999 }, makeRes());
|
||||
expect(immichSearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 1, 200);
|
||||
});
|
||||
|
||||
it('honours an explicit page and size within range', async () => {
|
||||
const immichSearchPhotos = vi.fn().mockResolvedValue({});
|
||||
const svc = makeService({ immichSearchPhotos });
|
||||
await new ImmichMemoriesController(svc).search(user, { page: 3, size: 25 }, makeRes());
|
||||
expect(immichSearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 3, 25);
|
||||
});
|
||||
|
||||
it('defaults assets to [] and hasMore to false when omitted', async () => {
|
||||
const svc = makeService({ immichSearchPhotos: vi.fn().mockResolvedValue({}) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).search(user, {}, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ assets: [], hasMore: false });
|
||||
});
|
||||
|
||||
it('maps the error envelope', async () => {
|
||||
const svc = makeService({ immichSearchPhotos: vi.fn().mockResolvedValue({ error: 'down', status: 502 }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).search(user, {}, res);
|
||||
expect(res.status).toHaveBeenCalledWith(502);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'down' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:tripId/:assetId/:ownerId/info', () => {
|
||||
it('400 on an invalid asset id', async () => {
|
||||
const immichIsValidAssetId = vi.fn().mockReturnValue(false);
|
||||
const svc = makeService({ immichIsValidAssetId });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetInfo(user, '5', 'bad', '2', res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid asset ID' });
|
||||
});
|
||||
|
||||
it('403 when access is denied', async () => {
|
||||
const svc = makeService({
|
||||
immichIsValidAssetId: vi.fn().mockReturnValue(true),
|
||||
canAccessUserPhoto: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res);
|
||||
expect(svc.canAccessUserPhoto).toHaveBeenCalledWith(7, 2, '5', 'a', 'immich');
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
|
||||
});
|
||||
|
||||
it('maps a service error after the guards pass', async () => {
|
||||
const svc = makeService({
|
||||
immichIsValidAssetId: vi.fn().mockReturnValue(true),
|
||||
canAccessUserPhoto: vi.fn().mockReturnValue(true),
|
||||
immichGetAssetInfo: vi.fn().mockResolvedValue({ error: 'Asset gone', status: 404 }),
|
||||
});
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Asset gone' });
|
||||
});
|
||||
|
||||
it('returns the asset data on success', async () => {
|
||||
const svc = makeService({
|
||||
immichIsValidAssetId: vi.fn().mockReturnValue(true),
|
||||
canAccessUserPhoto: vi.fn().mockReturnValue(true),
|
||||
immichGetAssetInfo: vi.fn().mockResolvedValue({ data: { id: 'a', takenAt: 't' } }),
|
||||
});
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res);
|
||||
expect(res.json).toHaveBeenCalledWith({ id: 'a', takenAt: 't' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/.../thumbnail + /original', () => {
|
||||
it('thumbnail: 400 on invalid id', async () => {
|
||||
const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(false) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'bad', '2', res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('thumbnail: 403 when access denied', async () => {
|
||||
const svc = makeService({
|
||||
immichIsValidAssetId: vi.fn().mockReturnValue(true),
|
||||
canAccessUserPhoto: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'a', '2', res);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('thumbnail: streams with kind=thumbnail when allowed', async () => {
|
||||
const immichStreamAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const svc = makeService({
|
||||
immichIsValidAssetId: vi.fn().mockReturnValue(true),
|
||||
canAccessUserPhoto: vi.fn().mockReturnValue(true),
|
||||
immichStreamAsset,
|
||||
});
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'a', '2', res);
|
||||
expect(immichStreamAsset).toHaveBeenCalledWith(res, 7, 'a', 'thumbnail', 2);
|
||||
});
|
||||
|
||||
it('original: 400 on invalid id', async () => {
|
||||
const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(false) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'bad', '2', res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('original: 403 when access denied', async () => {
|
||||
const svc = makeService({
|
||||
immichIsValidAssetId: vi.fn().mockReturnValue(true),
|
||||
canAccessUserPhoto: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'a', '2', res);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('original: streams with kind=original when allowed', async () => {
|
||||
const immichStreamAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const svc = makeService({
|
||||
immichIsValidAssetId: vi.fn().mockReturnValue(true),
|
||||
canAccessUserPhoto: vi.fn().mockReturnValue(true),
|
||||
immichStreamAsset,
|
||||
});
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'a', '2', res);
|
||||
expect(immichStreamAsset).toHaveBeenCalledWith(res, 7, 'a', 'original', 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /albums + /albums/:albumId/photos', () => {
|
||||
it('albums: returns the list on success', async () => {
|
||||
const svc = makeService({ immichListAlbums: vi.fn().mockResolvedValue({ albums: [{ id: 'a' }] }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).albums(user, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ albums: [{ id: 'a' }] });
|
||||
});
|
||||
|
||||
it('albums: maps the error envelope', async () => {
|
||||
const svc = makeService({ immichListAlbums: vi.fn().mockResolvedValue({ error: 'nope', status: 500 }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).albums(user, res);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'nope' });
|
||||
});
|
||||
|
||||
it('albumPhotos: returns the assets on success', async () => {
|
||||
const svc = makeService({ immichGetAlbumPhotos: vi.fn().mockResolvedValue({ assets: [{ id: 'p' }] }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).albumPhotos(user, 'al1', res);
|
||||
expect(svc.immichGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1');
|
||||
expect(res.json).toHaveBeenCalledWith({ assets: [{ id: 'p' }] });
|
||||
});
|
||||
|
||||
it('albumPhotos: maps the error envelope', async () => {
|
||||
const svc = makeService({ immichGetAlbumPhotos: vi.fn().mockResolvedValue({ error: 'gone', status: 404 }) });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).albumPhotos(user, 'al1', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'gone' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trips/:tripId/album-links/:linkId/sync', () => {
|
||||
it('maps the error envelope without broadcasting', async () => {
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ error: 'Link gone', status: 404 }), broadcast });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Link gone' });
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcasts when at least one asset was added', async () => {
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ added: 2, total: 10 }), broadcast });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res);
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true, added: 2, total: 10 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 7 }, 'sock');
|
||||
});
|
||||
|
||||
it('does not broadcast when nothing was added', async () => {
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ added: 0, total: 10 }), broadcast });
|
||||
const res = makeRes();
|
||||
await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res);
|
||||
expect(res.json).toHaveBeenCalledWith({ success: true, added: 0, total: 10 });
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('SynologyMemoriesController (parity with /api/integrations/memories/synologyphotos)', () => {
|
||||
describe('GET /settings + /status', () => {
|
||||
it('settings: returns the data on success', async () => {
|
||||
const svc = makeService({ synologyGetSettings: vi.fn().mockResolvedValue({ success: true, data: { synology_url: 'u' } }) });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).getSettings(user, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ synology_url: 'u' });
|
||||
});
|
||||
|
||||
it('settings: maps the error envelope', async () => {
|
||||
const svc = makeService({ synologyGetSettings: vi.fn().mockResolvedValue({ success: false, error: { status: 500, message: 'DB error' } }) });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).getSettings(user, res);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'DB error' });
|
||||
});
|
||||
|
||||
it('status: delegates', async () => {
|
||||
const svc = makeService({ synologyGetStatus: vi.fn().mockResolvedValue({ success: true, data: { connected: true } }) });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).getStatus(user, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ connected: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /settings', () => {
|
||||
it('400 when the url is missing', async () => {
|
||||
const synologyUpdateSettings = vi.fn();
|
||||
const svc = makeService({ synologyUpdateSettings });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).putSettings(user, { synology_username: 'admin' }, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'URL and username are required' });
|
||||
expect(synologyUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 when the username is missing', async () => {
|
||||
const synologyUpdateSettings = vi.fn();
|
||||
const svc = makeService({ synologyUpdateSettings });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).putSettings(user, { synology_url: 'http://nas' }, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(synologyUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates with trimmed values and the boolean skip-ssl flag (true keyword)', async () => {
|
||||
const synologyUpdateSettings = vi.fn().mockResolvedValue({ success: true, data: {} });
|
||||
const svc = makeService({ synologyUpdateSettings });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(
|
||||
svc,
|
||||
).putSettings(user, { synology_url: ' http://nas ', synology_username: ' admin ', synology_password: ' pw ', synology_skip_ssl: 'true' }, res);
|
||||
expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'http://nas', 'admin', 'pw', true);
|
||||
expect(res.json).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('treats a literal-true skip-ssl flag as true and other values as false', async () => {
|
||||
const synologyUpdateSettings = vi.fn().mockResolvedValue({ success: true, data: {} });
|
||||
const svc = makeService({ synologyUpdateSettings });
|
||||
await new SynologyMemoriesController(svc).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: true }, makeRes());
|
||||
expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', true);
|
||||
|
||||
const svc2 = makeService({ synologyUpdateSettings: vi.fn().mockResolvedValue({ success: true, data: {} }) });
|
||||
await new SynologyMemoriesController(svc2).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }, makeRes());
|
||||
expect(svc2.synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /test', () => {
|
||||
it('reports a single missing field with "is required"', async () => {
|
||||
const synologyTestConnection = vi.fn();
|
||||
const svc = makeService({ synologyTestConnection });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).test(user, { synology_url: 'u', synology_username: 'a' }, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ connected: false, error: 'Password is required' });
|
||||
expect(synologyTestConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports multiple missing fields with "are required"', async () => {
|
||||
const svc = makeService({ synologyTestConnection: vi.fn() });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).test(user, {}, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ connected: false, error: 'URL, Username, Password are required' });
|
||||
});
|
||||
|
||||
it('delegates when every field is present (otp + skip-ssl forwarded)', async () => {
|
||||
const synologyTestConnection = vi.fn().mockResolvedValue({ success: true, data: { connected: true } });
|
||||
const svc = makeService({ synologyTestConnection });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(
|
||||
svc,
|
||||
).test(user, { synology_url: 'u', synology_username: 'a', synology_password: 'p', synology_otp: '123', synology_skip_ssl: true }, res);
|
||||
expect(synologyTestConnection).toHaveBeenCalledWith(7, 'u', 'a', 'p', '123', true);
|
||||
expect(res.json).toHaveBeenCalledWith({ connected: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /albums + /albums/:albumId/photos', () => {
|
||||
it('albums: delegates', async () => {
|
||||
const svc = makeService({ synologyListAlbums: vi.fn().mockResolvedValue({ success: true, data: { albums: [] } }) });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).albums(user, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ albums: [] });
|
||||
});
|
||||
|
||||
it('albumPhotos: forwards a coerced passphrase when present', async () => {
|
||||
const synologyGetAlbumPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologyGetAlbumPhotos });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).albumPhotos(user, 'al1', 'secret', res);
|
||||
expect(synologyGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1', 'secret');
|
||||
expect(res.json).toHaveBeenCalledWith({ assets: [] });
|
||||
});
|
||||
|
||||
it('albumPhotos: passes undefined when the passphrase query is absent', async () => {
|
||||
const synologyGetAlbumPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologyGetAlbumPhotos });
|
||||
await new SynologyMemoriesController(svc).albumPhotos(user, 'al1', undefined, makeRes());
|
||||
expect(synologyGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trips/:tripId/album-links/:linkId/sync', () => {
|
||||
it('delegates and unwraps the success envelope', async () => {
|
||||
const synologySyncAlbumLink = vi.fn().mockResolvedValue({ success: true, data: { added: 1, total: 2 } });
|
||||
const svc = makeService({ synologySyncAlbumLink });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).sync(user, '5', 'l1', 'sock', res);
|
||||
expect(synologySyncAlbumLink).toHaveBeenCalledWith(7, '5', 'l1', 'sock');
|
||||
expect(res.json).toHaveBeenCalledWith({ added: 1, total: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /search', () => {
|
||||
it('uses the default offset/limit when nothing is provided', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, {}, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100);
|
||||
});
|
||||
|
||||
it('forwards from/to and uses size as the limit when size > 0', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { from: '2024-01-01', to: '2024-02-01', size: 30 }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, '2024-01-01', '2024-02-01', 0, 30);
|
||||
});
|
||||
|
||||
it('derives the offset from a 1-based page using the limit', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { page: 3, limit: 20 }, makeRes());
|
||||
// page-1 = 2, offset = 2 * 20 = 40
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 40, 20);
|
||||
});
|
||||
|
||||
it('keeps the explicit offset when page resolves to <= 0', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { page: 1, offset: 5, limit: 10 }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 5, 10);
|
||||
});
|
||||
|
||||
it('falls back to defaults when numeric fields are non-finite', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { offset: 'x', limit: 'y', page: 'z', size: 'q' }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:tripId/:photoId/:ownerId/info', () => {
|
||||
it('403 when access is denied', async () => {
|
||||
const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(false) });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', undefined, res);
|
||||
expect(svc.canAccessUserPhoto).toHaveBeenCalledWith(7, 2, '5', 'p1', 'synologyphotos');
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "You don't have access to this photo" });
|
||||
});
|
||||
|
||||
it('delegates with the coerced passphrase when access is granted', async () => {
|
||||
const synologyGetAssetInfo = vi.fn().mockResolvedValue({ success: true, data: { id: 'p1' } });
|
||||
const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyGetAssetInfo });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', 'secret', res);
|
||||
expect(synologyGetAssetInfo).toHaveBeenCalledWith(7, 'p1', 2, 'secret');
|
||||
expect(res.json).toHaveBeenCalledWith({ id: 'p1' });
|
||||
});
|
||||
|
||||
it('passes undefined passphrase when the query is absent', async () => {
|
||||
const synologyGetAssetInfo = vi.fn().mockResolvedValue({ success: true, data: { id: 'p1' } });
|
||||
const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyGetAssetInfo });
|
||||
await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', undefined, makeRes());
|
||||
expect(synologyGetAssetInfo).toHaveBeenCalledWith(7, 'p1', 2, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:tripId/:photoId/:ownerId/:kind', () => {
|
||||
it('400 on an invalid kind', async () => {
|
||||
const synologyStreamAsset = vi.fn();
|
||||
const svc = makeService({ synologyStreamAsset });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'preview', undefined, undefined, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid asset kind' });
|
||||
expect(synologyStreamAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 when access is denied for a valid kind', async () => {
|
||||
const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(false) });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', undefined, undefined, res);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "You don't have access to this photo" });
|
||||
});
|
||||
|
||||
it('streams a thumbnail, defaulting size to "sm" when omitted', async () => {
|
||||
const synologyStreamAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', undefined, undefined, res);
|
||||
expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'thumbnail', 'sm', undefined);
|
||||
});
|
||||
|
||||
it('keeps a whitelisted size and forwards the passphrase for an original', async () => {
|
||||
const synologyStreamAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'original', 'xl', 'secret', res);
|
||||
expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'original', 'xl', 'secret');
|
||||
});
|
||||
|
||||
it('coerces a non-whitelisted size back to "sm"', async () => {
|
||||
const synologyStreamAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset });
|
||||
const res = makeRes();
|
||||
await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', 'huge', undefined, res);
|
||||
expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'thumbnail', 'sm', undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The MemoriesService is a thin pass-through over the legacy services/memories/*
|
||||
// helpers. Mock each legacy module so we can assert the wrapper forwards every
|
||||
// argument unchanged (and exercise the optional-param call sites).
|
||||
|
||||
const unified = vi.hoisted(() => ({
|
||||
listTripPhotos: vi.fn(() => ({ data: [] })),
|
||||
listTripAlbumLinks: vi.fn(() => ({ data: [] })),
|
||||
createTripAlbumLink: vi.fn(() => ({ data: {} })),
|
||||
removeAlbumLink: vi.fn(() => ({ data: {} })),
|
||||
addTripPhotos: vi.fn(async () => ({ data: { added: 0 } })),
|
||||
removeTripPhoto: vi.fn(() => ({ data: {} })),
|
||||
setTripPhotoSharing: vi.fn(async () => ({ data: {} })),
|
||||
}));
|
||||
vi.mock('../../../src/services/memories/unifiedService', () => unified);
|
||||
|
||||
const immich = vi.hoisted(() => ({
|
||||
getConnectionSettings: vi.fn(() => ({})),
|
||||
saveImmichSettings: vi.fn(async () => ({ success: true })),
|
||||
setImmichAutoUpload: vi.fn(),
|
||||
testConnection: vi.fn(async () => ({ connected: true })),
|
||||
getConnectionStatus: vi.fn(async () => ({ connected: true })),
|
||||
browseTimeline: vi.fn(async () => ({ buckets: [] })),
|
||||
searchPhotos: vi.fn(async () => ({ assets: [] })),
|
||||
streamImmichAsset: vi.fn(async () => undefined),
|
||||
listAlbums: vi.fn(async () => ({ albums: [] })),
|
||||
getAlbumPhotos: vi.fn(async () => ({ assets: [] })),
|
||||
syncAlbumAssets: vi.fn(async () => ({ added: 0, total: 0 })),
|
||||
getAssetInfo: vi.fn(async () => ({ data: {} })),
|
||||
isValidAssetId: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock('../../../src/services/memories/immichService', () => immich);
|
||||
|
||||
const synology = vi.hoisted(() => ({
|
||||
getSynologySettings: vi.fn(async () => ({ success: true, data: {} })),
|
||||
updateSynologySettings: vi.fn(async () => ({ success: true, data: {} })),
|
||||
getSynologyStatus: vi.fn(async () => ({ success: true, data: {} })),
|
||||
testSynologyConnection: vi.fn(async () => ({ success: true, data: {} })),
|
||||
listSynologyAlbums: vi.fn(async () => ({ success: true, data: {} })),
|
||||
getSynologyAlbumPhotos: vi.fn(async () => ({ success: true, data: {} })),
|
||||
syncSynologyAlbumLink: vi.fn(async () => ({ success: true, data: {} })),
|
||||
searchSynologyPhotos: vi.fn(async () => ({ success: true, data: {} })),
|
||||
getSynologyAssetInfo: vi.fn(async () => ({ success: true, data: {} })),
|
||||
streamSynologyAsset: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock('../../../src/services/memories/synologyService', () => synology);
|
||||
|
||||
const helpers = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/memories/helpersService', () => helpers);
|
||||
|
||||
const ws = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ws);
|
||||
|
||||
import { MemoriesService } from '../../../src/nest/memories/memories.service';
|
||||
|
||||
const res = {} as import('express').Response;
|
||||
|
||||
describe('MemoriesService (delegation wrapper over services/memories/*)', () => {
|
||||
let svc: MemoriesService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
svc = new MemoriesService();
|
||||
});
|
||||
|
||||
it('access check + broadcast forward verbatim', () => {
|
||||
helpers.canAccessUserPhoto.mockReturnValue(false);
|
||||
expect(svc.canAccessUserPhoto(1, 2, '5', 'a', 'immich')).toBe(false);
|
||||
expect(helpers.canAccessUserPhoto).toHaveBeenCalledWith(1, 2, '5', 'a', 'immich');
|
||||
|
||||
svc.broadcast('5', 'memories:updated', { userId: 1 }, 'sock');
|
||||
expect(ws.broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 1 }, 'sock');
|
||||
});
|
||||
|
||||
it('broadcast forwards an absent socket id as undefined', () => {
|
||||
svc.broadcast('5', 'memories:updated', { userId: 1 });
|
||||
expect(ws.broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 1 }, undefined);
|
||||
});
|
||||
|
||||
it('unified methods delegate', async () => {
|
||||
svc.listTripPhotos('5', 7);
|
||||
expect(unified.listTripPhotos).toHaveBeenCalledWith('5', 7);
|
||||
|
||||
const selections = [{ provider: 'immich', asset_ids: ['a'] }];
|
||||
await svc.addTripPhotos('5', 7, true, selections, 'sock');
|
||||
expect(unified.addTripPhotos).toHaveBeenCalledWith('5', 7, true, selections, 'sock');
|
||||
|
||||
await svc.setTripPhotoSharing('5', 7, 9, false);
|
||||
expect(unified.setTripPhotoSharing).toHaveBeenCalledWith('5', 7, 9, false);
|
||||
|
||||
svc.removeTripPhoto('5', 7, 9);
|
||||
expect(unified.removeTripPhoto).toHaveBeenCalledWith('5', 7, 9);
|
||||
|
||||
svc.listTripAlbumLinks('5', 7);
|
||||
expect(unified.listTripAlbumLinks).toHaveBeenCalledWith('5', 7);
|
||||
|
||||
svc.removeAlbumLink('5', 'l1', 7);
|
||||
expect(unified.removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7);
|
||||
});
|
||||
|
||||
it('createTripAlbumLink forwards a passphrase when present and omits it when absent', () => {
|
||||
svc.createTripAlbumLink('5', 7, 'immich', 'a1', 'Trip', 'secret');
|
||||
expect(unified.createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'immich', 'a1', 'Trip', 'secret');
|
||||
|
||||
svc.createTripAlbumLink('5', 7, 'immich', 'a1', 'Trip');
|
||||
expect(unified.createTripAlbumLink).toHaveBeenLastCalledWith('5', 7, 'immich', 'a1', 'Trip', undefined);
|
||||
});
|
||||
|
||||
it('immich methods delegate', async () => {
|
||||
svc.immichGetConnectionSettings(7);
|
||||
expect(immich.getConnectionSettings).toHaveBeenCalledWith(7);
|
||||
|
||||
await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4');
|
||||
expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4');
|
||||
|
||||
svc.immichSetAutoUpload(7, true);
|
||||
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(7, true);
|
||||
|
||||
await svc.immichGetConnectionStatus(7);
|
||||
expect(immich.getConnectionStatus).toHaveBeenCalledWith(7);
|
||||
|
||||
await svc.immichTestConnection('u', 'k');
|
||||
expect(immich.testConnection).toHaveBeenCalledWith('u', 'k');
|
||||
|
||||
await svc.immichBrowseTimeline(7);
|
||||
expect(immich.browseTimeline).toHaveBeenCalledWith(7);
|
||||
|
||||
await svc.immichSearchPhotos(7, 'f', 't', 2, 50);
|
||||
expect(immich.searchPhotos).toHaveBeenCalledWith(7, 'f', 't', 2, 50);
|
||||
|
||||
expect(svc.immichIsValidAssetId('abc')).toBe(true);
|
||||
expect(immich.isValidAssetId).toHaveBeenCalledWith('abc');
|
||||
|
||||
await svc.immichGetAssetInfo(7, 'a', 2);
|
||||
expect(immich.getAssetInfo).toHaveBeenCalledWith(7, 'a', 2);
|
||||
|
||||
await svc.immichStreamAsset(res, 7, 'a', 'thumbnail', 2);
|
||||
expect(immich.streamImmichAsset).toHaveBeenCalledWith(res, 7, 'a', 'thumbnail', 2);
|
||||
|
||||
await svc.immichListAlbums(7);
|
||||
expect(immich.listAlbums).toHaveBeenCalledWith(7);
|
||||
|
||||
await svc.immichGetAlbumPhotos(7, 'al1');
|
||||
expect(immich.getAlbumPhotos).toHaveBeenCalledWith(7, 'al1');
|
||||
|
||||
await svc.immichSyncAlbumAssets('5', 'l1', 7, 'sock');
|
||||
expect(immich.syncAlbumAssets).toHaveBeenCalledWith('5', 'l1', 7, 'sock');
|
||||
});
|
||||
|
||||
it('synology methods delegate', async () => {
|
||||
await svc.synologyGetSettings(7);
|
||||
expect(synology.getSynologySettings).toHaveBeenCalledWith(7);
|
||||
|
||||
await svc.synologyUpdateSettings(7, 'u', 'a', 'p', true);
|
||||
expect(synology.updateSynologySettings).toHaveBeenCalledWith(7, 'u', 'a', 'p', true);
|
||||
|
||||
await svc.synologyGetStatus(7);
|
||||
expect(synology.getSynologyStatus).toHaveBeenCalledWith(7);
|
||||
|
||||
await svc.synologyTestConnection(7, 'u', 'a', 'p', '123', false);
|
||||
expect(synology.testSynologyConnection).toHaveBeenCalledWith(7, 'u', 'a', 'p', '123', false);
|
||||
|
||||
await svc.synologyListAlbums(7);
|
||||
expect(synology.listSynologyAlbums).toHaveBeenCalledWith(7);
|
||||
|
||||
await svc.synologySyncAlbumLink(7, '5', 'l1', 'sock');
|
||||
expect(synology.syncSynologyAlbumLink).toHaveBeenCalledWith(7, '5', 'l1', 'sock');
|
||||
|
||||
await svc.synologySearchPhotos(7, 'f', 't', 0, 100);
|
||||
expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(7, 'f', 't', 0, 100);
|
||||
});
|
||||
|
||||
it('synology album-photos forwards a passphrase when present and omits it when absent', async () => {
|
||||
await svc.synologyGetAlbumPhotos(7, 'al1', 'secret');
|
||||
expect(synology.getSynologyAlbumPhotos).toHaveBeenCalledWith(7, 'al1', 'secret');
|
||||
|
||||
await svc.synologyGetAlbumPhotos(7, 'al1');
|
||||
expect(synology.getSynologyAlbumPhotos).toHaveBeenLastCalledWith(7, 'al1', undefined);
|
||||
});
|
||||
|
||||
it('synology asset-info + stream forward a passphrase when present and omit it when absent', async () => {
|
||||
await svc.synologyGetAssetInfo(7, 'p1', 2, 'secret');
|
||||
expect(synology.getSynologyAssetInfo).toHaveBeenCalledWith(7, 'p1', 2, 'secret');
|
||||
|
||||
await svc.synologyGetAssetInfo(7, 'p1', 2);
|
||||
expect(synology.getSynologyAssetInfo).toHaveBeenLastCalledWith(7, 'p1', 2, undefined);
|
||||
|
||||
await svc.synologyStreamAsset(res, 7, 2, 'p1', 'thumbnail', 'sm', 'secret');
|
||||
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'thumbnail', 'sm', 'secret');
|
||||
|
||||
await svc.synologyStreamAsset(res, 7, 2, 'p1', 'original', 'xl');
|
||||
expect(synology.streamSynologyAsset).toHaveBeenLastCalledWith(res, 7, 2, 'p1', 'original', 'xl', undefined);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logWarn: vi.fn() }));
|
||||
|
||||
import { getClientIp } from '../../../src/services/auditLog';
|
||||
const getClientIpMock = vi.mocked(getClientIp);
|
||||
|
||||
import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.controller';
|
||||
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
|
||||
import { RateLimitService } from '../../../src/nest/auth/rate-limit.service';
|
||||
@@ -142,6 +145,95 @@ describe('OauthPublicController /token', () => {
|
||||
new OauthPublicController(osvc(), s).token(reqWith({ client_id: 'c' }), res);
|
||||
expect(res.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('falls back to {} when the body is not an object', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).token({ ip: '7.7.7.7', body: 'not-an-object' } as unknown as Request, res);
|
||||
// no client_id in the {} fallback -> 401
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||
});
|
||||
|
||||
it('authorization_code: invalid client secret writes an audit + 401', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({
|
||||
consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }),
|
||||
authenticateClient: vi.fn().mockReturnValue(null),
|
||||
}), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
});
|
||||
|
||||
it('refresh_token: invalid_client maps to its specific 401 message', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl())
|
||||
.token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
});
|
||||
|
||||
it('refresh_token: defaults the status to 400 when the service omits it', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant' }) }), rl())
|
||||
.token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('client_credentials: 401 when the client cannot be authenticated', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
|
||||
.token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
});
|
||||
|
||||
it('client_credentials: honours a valid requested scope subset', () => {
|
||||
const res = makeRes();
|
||||
const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' });
|
||||
new OauthPublicController(osvc({
|
||||
authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }),
|
||||
issueClientCredentialsToken,
|
||||
}), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a' }), res);
|
||||
expect(res.body).toEqual({ access_token: 'cc_at' });
|
||||
expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], expect.any(String));
|
||||
});
|
||||
|
||||
it('client_credentials: derives the audience from an explicit resource', () => {
|
||||
const res = makeRes();
|
||||
const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' });
|
||||
new OauthPublicController(osvc({
|
||||
authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }),
|
||||
issueClientCredentialsToken,
|
||||
}), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', resource: 'https://aud/' }), res);
|
||||
// trailing slashes are trimmed, not the mcpSafeUrl fallback
|
||||
expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], 'https://aud');
|
||||
});
|
||||
|
||||
it('logs a dash for a missing ip on the authorization_code client-auth failure', () => {
|
||||
getClientIpMock.mockReturnValueOnce(undefined);
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({
|
||||
consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }),
|
||||
authenticateClient: vi.fn().mockReturnValue(null),
|
||||
}), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('logs a dash for a missing ip on the refresh invalid_client failure', () => {
|
||||
getClientIpMock.mockReturnValueOnce(undefined);
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl())
|
||||
.token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('logs a dash for a missing ip on the client_credentials auth failure', () => {
|
||||
getClientIpMock.mockReturnValueOnce(undefined);
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
|
||||
.token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OauthPublicController /userinfo + /revoke', () => {
|
||||
@@ -155,6 +247,21 @@ describe('OauthPublicController /userinfo + /revoke', () => {
|
||||
expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' });
|
||||
});
|
||||
|
||||
it('userinfo: 404 empty when MCP is disabled', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.ended).toBe(true);
|
||||
});
|
||||
|
||||
it('userinfo: 401 with the error challenge when the token is unknown', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ getUserByAccessToken: vi.fn().mockReturnValue(null) }), rl()).userinfo('Bearer tok', res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP", error="invalid_token"');
|
||||
expect(res.body).toEqual({ error: 'invalid_token' });
|
||||
});
|
||||
|
||||
it('revoke: 400 without token/client, always 200 once authenticated', () => {
|
||||
const r1 = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1);
|
||||
@@ -166,6 +273,45 @@ describe('OauthPublicController /userinfo + /revoke', () => {
|
||||
expect(r2.body).toEqual({});
|
||||
expect(revokeToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revoke: 404 empty when MCP is disabled', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).revoke({ ip: '1', body: {} } as Request, res);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.ended).toBe(true);
|
||||
});
|
||||
|
||||
it('revoke: 429 when the per-ip bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
for (let i = 0; i < 10; i++) s.check('oauth_revoke', '1', 10, 60000, Date.now());
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc(), s).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
|
||||
expect(res.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('revoke: falls back to a default ip key and {} body when both are missing', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken: vi.fn() }), rl())
|
||||
.revoke({ body: undefined } as unknown as Request, res);
|
||||
// body fell back to {} -> token/client missing -> 400
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('revoke: 401 when the client credentials are invalid', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
|
||||
.revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
});
|
||||
|
||||
it('revoke: logs a dash for a missing ip on the invalid-client failure', () => {
|
||||
getClientIpMock.mockReturnValueOnce(undefined);
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
|
||||
.revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OauthApiController', () => {
|
||||
@@ -215,4 +361,66 @@ describe('OauthApiController', () => {
|
||||
expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'invalid_scope', error_description: 'bad' }) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 400, body: { error: 'invalid_scope', error_description: 'bad' } });
|
||||
expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue(null) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 503, body: { error: 'server_error', error_description: 'Authorization server is temporarily unavailable' } });
|
||||
});
|
||||
|
||||
it('validate: 429 when the per-ip bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
for (let i = 0; i < 30; i++) s.check('oauth_validate', '1.2.3.4', 30, 60000, Date.now());
|
||||
const res = makeRes2();
|
||||
expect(thrown(() => new OauthApiController(osvc(), s).validate({ ...req } as Request, {}, res))).toEqual({
|
||||
status: 429,
|
||||
body: { error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('validate: falls back to the "unknown" rate-limit key when req.ip is absent', () => {
|
||||
const res = makeRes2();
|
||||
const out = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl())
|
||||
.validate({ user: undefined } as unknown as Request, {}, res);
|
||||
expect(out).toEqual({ valid: true, loginRequired: true });
|
||||
});
|
||||
|
||||
it('validate: forwards the resource + returns the raw result for a logged-in user', () => {
|
||||
const res = makeRes2();
|
||||
const validateAuthorizeRequest = vi.fn().mockReturnValue({ valid: true, scopes: ['s'] });
|
||||
const out = new OauthApiController(osvc({ validateAuthorizeRequest }), rl())
|
||||
.validate({ ...req, user: { id: 9 } } as unknown as Request, { resource: 'https://r' }, res);
|
||||
expect(out).toEqual({ valid: true, scopes: ['s'] });
|
||||
expect(validateAuthorizeRequest).toHaveBeenCalledWith(expect.objectContaining({ resource: 'https://r' }), 9);
|
||||
});
|
||||
|
||||
it('authorize: 403 when MCP is disabled', () => {
|
||||
expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl())
|
||||
.authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req)))
|
||||
.toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
|
||||
});
|
||||
|
||||
it('authorize: carries the state through both the denied and approved redirects', () => {
|
||||
const denied = new OauthApiController(osvc(), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req);
|
||||
expect((denied as { redirect: string }).redirect).toContain('state=xyz');
|
||||
|
||||
const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: 'https://aud' }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') });
|
||||
const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req);
|
||||
expect((ok as { redirect: string }).redirect).toContain('code=the_code');
|
||||
expect((ok as { redirect: string }).redirect).toContain('state=xyz');
|
||||
});
|
||||
|
||||
it('client/session errors default the status to 400 when the service omits it', () => {
|
||||
expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req)))
|
||||
.toEqual({ status: 400, body: { error: 'bad' } });
|
||||
expect(thrown(() => new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).rotateClient(user, 'c1', req)))
|
||||
.toEqual({ status: 400, body: { error: 'bad' } });
|
||||
expect(thrown(() => new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).deleteClient(user, 'c1', req)))
|
||||
.toEqual({ status: 404, body: { error: 'not_found' } });
|
||||
expect(thrown(() => new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).deleteClient(user, 'c1', req)))
|
||||
.toEqual({ status: 400, body: { error: 'bad' } });
|
||||
expect(thrown(() => new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).revokeSession(user, '1', req)))
|
||||
.toEqual({ status: 404, body: { error: 'not_found' } });
|
||||
expect(thrown(() => new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).revokeSession(user, '1', req)))
|
||||
.toEqual({ status: 400, body: { error: 'bad' } });
|
||||
});
|
||||
|
||||
it('sessions: 403 when MCP is off on the list', () => {
|
||||
expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listSessions(user)))
|
||||
.toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The Nest service is a thin wrapper that forwards to the legacy oauthService
|
||||
// plus the addon/notification helpers. Mock those and assert the delegation.
|
||||
const { oauth } = vi.hoisted(() => ({
|
||||
oauth: {
|
||||
consumeAuthCode: vi.fn(),
|
||||
authenticateClient: vi.fn(),
|
||||
verifyPKCE: vi.fn(),
|
||||
issueTokens: vi.fn(),
|
||||
issueClientCredentialsToken: vi.fn(),
|
||||
refreshTokens: vi.fn(),
|
||||
revokeToken: vi.fn(),
|
||||
getUserByAccessToken: vi.fn(),
|
||||
validateAuthorizeRequest: vi.fn(),
|
||||
saveConsent: vi.fn(),
|
||||
createAuthCode: vi.fn(),
|
||||
listOAuthClients: vi.fn(),
|
||||
createOAuthClient: vi.fn(),
|
||||
rotateOAuthClientSecret: vi.fn(),
|
||||
deleteOAuthClient: vi.fn(),
|
||||
listOAuthSessions: vi.fn(),
|
||||
revokeSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/oauthService', () => oauth);
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn() }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
|
||||
const { getMcpSafeUrl } = vi.hoisted(() => ({ getMcpSafeUrl: vi.fn() }));
|
||||
vi.mock('../../../src/services/notifications', () => ({ getMcpSafeUrl }));
|
||||
|
||||
import { OauthService } from '../../../src/nest/oauth/oauth.service';
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
|
||||
function svc() { return new OauthService(); }
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('OauthService', () => {
|
||||
it('mcpEnabled checks the MCP addon flag', () => {
|
||||
isAddonEnabled.mockReturnValue(true);
|
||||
expect(svc().mcpEnabled()).toBe(true);
|
||||
expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.MCP);
|
||||
isAddonEnabled.mockReturnValue(false);
|
||||
expect(svc().mcpEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('mcpSafeUrl forwards to the notifications helper', () => {
|
||||
getMcpSafeUrl.mockReturnValue('https://safe');
|
||||
expect(svc().mcpSafeUrl()).toBe('https://safe');
|
||||
expect(getMcpSafeUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('consumeAuthCode delegates', () => {
|
||||
oauth.consumeAuthCode.mockReturnValue({ clientId: 'c' });
|
||||
expect(svc().consumeAuthCode('code')).toEqual({ clientId: 'c' });
|
||||
expect(oauth.consumeAuthCode).toHaveBeenCalledWith('code');
|
||||
});
|
||||
|
||||
it('authenticateClient delegates with both args', () => {
|
||||
oauth.authenticateClient.mockReturnValue({ id: 'c' });
|
||||
expect(svc().authenticateClient('c', 'secret')).toEqual({ id: 'c' });
|
||||
expect(oauth.authenticateClient).toHaveBeenCalledWith('c', 'secret');
|
||||
});
|
||||
|
||||
it('verifyPKCE delegates', () => {
|
||||
oauth.verifyPKCE.mockReturnValue(true);
|
||||
expect(svc().verifyPKCE('v', 'ch')).toBe(true);
|
||||
expect(oauth.verifyPKCE).toHaveBeenCalledWith('v', 'ch');
|
||||
});
|
||||
|
||||
it('issueTokens forwards the full argument list', () => {
|
||||
oauth.issueTokens.mockReturnValue({ access_token: 'at' });
|
||||
expect(svc().issueTokens('c', 1, ['s'], null, 'aud')).toEqual({ access_token: 'at' });
|
||||
expect(oauth.issueTokens).toHaveBeenCalledWith('c', 1, ['s'], null, 'aud');
|
||||
});
|
||||
|
||||
it('issueClientCredentialsToken forwards the full argument list', () => {
|
||||
oauth.issueClientCredentialsToken.mockReturnValue({ access_token: 'cc' });
|
||||
expect(svc().issueClientCredentialsToken('c', 1, ['s'], 'aud')).toEqual({ access_token: 'cc' });
|
||||
expect(oauth.issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['s'], 'aud');
|
||||
});
|
||||
|
||||
it('refreshTokens forwards the full argument list', () => {
|
||||
oauth.refreshTokens.mockReturnValue({ tokens: { access_token: 'new' } });
|
||||
expect(svc().refreshTokens('rt', 'c', 's', '1.2.3.4')).toEqual({ tokens: { access_token: 'new' } });
|
||||
expect(oauth.refreshTokens).toHaveBeenCalledWith('rt', 'c', 's', '1.2.3.4');
|
||||
});
|
||||
|
||||
it('revokeToken forwards the full argument list', () => {
|
||||
svc().revokeToken('t', 'c', undefined, '1.2.3.4');
|
||||
expect(oauth.revokeToken).toHaveBeenCalledWith('t', 'c', undefined, '1.2.3.4');
|
||||
});
|
||||
|
||||
it('getUserByAccessToken delegates', () => {
|
||||
oauth.getUserByAccessToken.mockReturnValue({ user: { id: 1 } });
|
||||
expect(svc().getUserByAccessToken('tok')).toEqual({ user: { id: 1 } });
|
||||
expect(oauth.getUserByAccessToken).toHaveBeenCalledWith('tok');
|
||||
});
|
||||
|
||||
it('validateAuthorizeRequest delegates with the user id', () => {
|
||||
oauth.validateAuthorizeRequest.mockReturnValue({ valid: true });
|
||||
const params = { response_type: 'code' } as never;
|
||||
expect(svc().validateAuthorizeRequest(params, 5)).toEqual({ valid: true });
|
||||
expect(oauth.validateAuthorizeRequest).toHaveBeenCalledWith(params, 5);
|
||||
});
|
||||
|
||||
it('saveConsent forwards the full argument list', () => {
|
||||
svc().saveConsent('c', 1, ['s'], '1.2.3.4');
|
||||
expect(oauth.saveConsent).toHaveBeenCalledWith('c', 1, ['s'], '1.2.3.4');
|
||||
});
|
||||
|
||||
it('createAuthCode forwards the params object', () => {
|
||||
oauth.createAuthCode.mockReturnValue('the_code');
|
||||
const p = { clientId: 'c', userId: 1, redirectUri: 'u', scopes: ['s'], resource: null, codeChallenge: 'cc', codeChallengeMethod: 'S256' } as const;
|
||||
expect(svc().createAuthCode(p)).toBe('the_code');
|
||||
expect(oauth.createAuthCode).toHaveBeenCalledWith(p);
|
||||
});
|
||||
|
||||
it('listOAuthClients delegates', () => {
|
||||
oauth.listOAuthClients.mockReturnValue([{ id: 'c1' }]);
|
||||
expect(svc().listOAuthClients(1)).toEqual([{ id: 'c1' }]);
|
||||
expect(oauth.listOAuthClients).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('createOAuthClient forwards the full argument list', () => {
|
||||
oauth.createOAuthClient.mockReturnValue({ client_id: 'c1' });
|
||||
expect(svc().createOAuthClient(1, 'CLI', ['https://cb'], ['a'], '1.2.3.4', { allowsClientCredentials: true })).toEqual({ client_id: 'c1' });
|
||||
expect(oauth.createOAuthClient).toHaveBeenCalledWith(1, 'CLI', ['https://cb'], ['a'], '1.2.3.4', { allowsClientCredentials: true });
|
||||
});
|
||||
|
||||
it('rotateOAuthClientSecret delegates', () => {
|
||||
oauth.rotateOAuthClientSecret.mockReturnValue({ client_secret: 'new' });
|
||||
expect(svc().rotateOAuthClientSecret(1, 'c1', '1.2.3.4')).toEqual({ client_secret: 'new' });
|
||||
expect(oauth.rotateOAuthClientSecret).toHaveBeenCalledWith(1, 'c1', '1.2.3.4');
|
||||
});
|
||||
|
||||
it('deleteOAuthClient delegates', () => {
|
||||
oauth.deleteOAuthClient.mockReturnValue({});
|
||||
expect(svc().deleteOAuthClient(1, 'c1', '1.2.3.4')).toEqual({});
|
||||
expect(oauth.deleteOAuthClient).toHaveBeenCalledWith(1, 'c1', '1.2.3.4');
|
||||
});
|
||||
|
||||
it('listOAuthSessions delegates', () => {
|
||||
oauth.listOAuthSessions.mockReturnValue([{ id: 1 }]);
|
||||
expect(svc().listOAuthSessions(1)).toEqual([{ id: 1 }]);
|
||||
expect(oauth.listOAuthSessions).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('revokeSession delegates', () => {
|
||||
oauth.revokeSession.mockReturnValue({});
|
||||
expect(svc().revokeSession(1, 7, '1.2.3.4')).toEqual({});
|
||||
expect(oauth.revokeSession).toHaveBeenCalledWith(1, 7, '1.2.3.4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OauthModule', () => {
|
||||
it('wires the public + api controllers and the providers', async () => {
|
||||
const { OauthModule } = await import('../../../src/nest/oauth/oauth.module');
|
||||
const { OauthPublicController } = await import('../../../src/nest/oauth/oauth-public.controller');
|
||||
const { OauthApiController } = await import('../../../src/nest/oauth/oauth-api.controller');
|
||||
const { OauthService: Svc } = await import('../../../src/nest/oauth/oauth.service');
|
||||
const { RateLimitService } = await import('../../../src/nest/auth/rate-limit.service');
|
||||
|
||||
const controllers = Reflect.getMetadata('controllers', OauthModule);
|
||||
const providers = Reflect.getMetadata('providers', OauthModule);
|
||||
expect(controllers).toEqual([OauthPublicController, OauthApiController]);
|
||||
expect(providers).toEqual([Svc, RateLimitService]);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,59 @@ describe('OidcController /login', () => {
|
||||
expect(res.redirectedTo).toContain('code_challenge=cc');
|
||||
expect(res.redirectedTo).toContain('code_challenge_method=S256');
|
||||
});
|
||||
|
||||
it('400 when a non-HTTPS issuer is used in production', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'OIDC issuer must use HTTPS in production' });
|
||||
});
|
||||
|
||||
it('allows a non-HTTPS issuer outside production', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res);
|
||||
expect(res.redirect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('500 when APP_URL is not configured', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ getAppUrl: vi.fn().mockReturnValue('') })).login(req, res);
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
||||
});
|
||||
|
||||
it('passes the invite token from the query into createState', async () => {
|
||||
const res = makeRes();
|
||||
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||
const reqInvite = { query: { invite: 'tok123' }, headers: {} } as unknown as Request;
|
||||
await new OidcController(svc({ createState })).login(reqInvite, res);
|
||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123');
|
||||
});
|
||||
|
||||
it('trims a trailing slash off APP_URL when building the redirect uri', async () => {
|
||||
const res = makeRes();
|
||||
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||
await new OidcController(svc({ getAppUrl: vi.fn().mockReturnValue('https://app///'), createState })).login(req, res);
|
||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined);
|
||||
});
|
||||
|
||||
it('500 when discovery throws', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ discover: vi.fn().mockRejectedValue(new Error('boom')) })).login(req, res);
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'OIDC login failed' });
|
||||
});
|
||||
|
||||
it('500 logs a non-Error rejection without crashing', async () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ discover: vi.fn().mockRejectedValue('plain string') })).login(req, res);
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(spy).toHaveBeenCalledWith('[OIDC] Login error:', 'plain string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OidcController /callback', () => {
|
||||
@@ -131,6 +184,145 @@ describe('OidcController /callback', () => {
|
||||
await c.callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=subject_mismatch');
|
||||
});
|
||||
|
||||
it('redirects invalid_state when there is no bound state cookie at all', async () => {
|
||||
const res = makeRes();
|
||||
const reqNoCookie = { query: {}, headers: {}, cookies: {} } as unknown as Request;
|
||||
await new OidcController(svc()).callback('c', 's', undefined, reqNoCookie, res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
||||
});
|
||||
|
||||
it('tolerates a request with no cookies object', async () => {
|
||||
const res = makeRes();
|
||||
const reqNoCookies = { query: {}, headers: {} } as unknown as Request;
|
||||
await new OidcController(svc()).callback('c', 's', undefined, reqNoCookies, res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
||||
});
|
||||
|
||||
it('redirects not_configured when the config disappears mid-flow', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=not_configured');
|
||||
});
|
||||
|
||||
it('redirects issuer_not_https when a non-HTTPS issuer is used in production', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=issuer_not_https');
|
||||
});
|
||||
|
||||
it('redirects token_failed when the token exchange is not ok', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: false, _status: 401 }) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed');
|
||||
});
|
||||
|
||||
it('redirects token_failed when the access token is missing', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true }) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed');
|
||||
});
|
||||
|
||||
it('redirects id_token_invalid when verification fails with a reason', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: false, error: 'bad_signature' }),
|
||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=id_token_invalid');
|
||||
});
|
||||
|
||||
it('redirects id_token_invalid when verification fails without an error field', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: false }),
|
||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=id_token_invalid');
|
||||
});
|
||||
|
||||
it('falls back to config.issuer when the discovery doc has no issuer', async () => {
|
||||
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({
|
||||
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui' }),
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken,
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||
// doc.issuer absent → (doc.issuer ?? '') is '' → falls back to config.issuer
|
||||
expect(verifyIdToken).toHaveBeenCalledWith('it', expect.anything(), 'c', 'https://idp');
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('strips trailing slashes off the discovery doc issuer before verifying', async () => {
|
||||
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({
|
||||
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp/' }),
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken,
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(verifyIdToken).toHaveBeenCalledWith('it', expect.anything(), 'c', 'https://idp');
|
||||
});
|
||||
|
||||
it('redirects no_email when the userinfo has no email', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ sub: 'u1' }),
|
||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=no_email');
|
||||
});
|
||||
|
||||
it('accepts when userinfo omits sub (no cross-check to run)', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('accepts when the id_token claims have a non-string sub (cross-check skipped)', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 12345 } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'something-else' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('surfaces a findOrCreateUser provisioning error', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ error: 'registration_disabled' }),
|
||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=registration_disabled');
|
||||
});
|
||||
|
||||
it('redirects server_error when the flow throws', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ discover: vi.fn().mockRejectedValue(new Error('network down')) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=server_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OidcController /exchange', () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
// The Nest service is a thin pass-through to the legacy OIDC helpers plus a few
|
||||
// adjacent service modules. Mock each one and assert the wrapper forwards every
|
||||
// argument and returns whatever the legacy function hands back.
|
||||
const { oidc } = vi.hoisted(() => ({
|
||||
oidc: {
|
||||
getOidcConfig: vi.fn(),
|
||||
discover: vi.fn(),
|
||||
createState: vi.fn(),
|
||||
consumeState: vi.fn(),
|
||||
exchangeCodeForToken: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
findOrCreateUser: vi.fn(),
|
||||
touchLastLogin: vi.fn(),
|
||||
generateToken: vi.fn(),
|
||||
createAuthCode: vi.fn(),
|
||||
consumeAuthCode: vi.fn(),
|
||||
frontendUrl: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/oidcService', () => oidc);
|
||||
|
||||
const { getAppUrl } = vi.hoisted(() => ({ getAppUrl: vi.fn() }));
|
||||
vi.mock('../../../src/services/notifications', () => ({ getAppUrl }));
|
||||
|
||||
const { resolveAuthToggles } = vi.hoisted(() => ({ resolveAuthToggles: vi.fn() }));
|
||||
vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles }));
|
||||
|
||||
const { setAuthCookie } = vi.hoisted(() => ({ setAuthCookie: vi.fn() }));
|
||||
vi.mock('../../../src/services/cookie', () => ({ setAuthCookie }));
|
||||
|
||||
import { OidcService } from '../../../src/nest/oidc/oidc.service';
|
||||
|
||||
let s: OidcService;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
s = new OidcService();
|
||||
});
|
||||
|
||||
describe('OidcService', () => {
|
||||
it('oidcLoginEnabled reads the resolved auth toggle', () => {
|
||||
resolveAuthToggles.mockReturnValue({ oidc_login: true });
|
||||
expect(s.oidcLoginEnabled()).toBe(true);
|
||||
resolveAuthToggles.mockReturnValue({ oidc_login: false });
|
||||
expect(s.oidcLoginEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('getOidcConfig delegates to the legacy helper', () => {
|
||||
const cfg = { issuer: 'https://idp' };
|
||||
oidc.getOidcConfig.mockReturnValue(cfg);
|
||||
expect(s.getOidcConfig()).toBe(cfg);
|
||||
});
|
||||
|
||||
it('getAppUrl delegates to notifications.getAppUrl', () => {
|
||||
getAppUrl.mockReturnValue('https://app');
|
||||
expect(s.getAppUrl()).toBe('https://app');
|
||||
});
|
||||
|
||||
it('discover forwards the issuer and discovery url', () => {
|
||||
const doc = { authorization_endpoint: 'https://idp/auth' };
|
||||
oidc.discover.mockReturnValue(doc);
|
||||
expect(s.discover('https://idp', 'https://idp/.well-known')).toBe(doc);
|
||||
expect(oidc.discover).toHaveBeenCalledWith('https://idp', 'https://idp/.well-known');
|
||||
});
|
||||
|
||||
it('discover works without a discovery url', () => {
|
||||
oidc.discover.mockReturnValue('doc');
|
||||
expect(s.discover('https://idp')).toBe('doc');
|
||||
expect(oidc.discover).toHaveBeenCalledWith('https://idp', undefined);
|
||||
});
|
||||
|
||||
it('createState forwards the redirect uri and invite token', () => {
|
||||
const st = { state: 'st', codeChallenge: 'cc' };
|
||||
oidc.createState.mockReturnValue(st);
|
||||
expect(s.createState('https://app/cb', 'inv')).toBe(st);
|
||||
expect(oidc.createState).toHaveBeenCalledWith('https://app/cb', 'inv');
|
||||
});
|
||||
|
||||
it('createState works without an invite token', () => {
|
||||
oidc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||
s.createState('https://app/cb');
|
||||
expect(oidc.createState).toHaveBeenCalledWith('https://app/cb', undefined);
|
||||
});
|
||||
|
||||
it('consumeState forwards the state', () => {
|
||||
oidc.consumeState.mockReturnValue({ redirectUri: 'r', codeVerifier: 'v' });
|
||||
expect(s.consumeState('st')).toEqual({ redirectUri: 'r', codeVerifier: 'v' });
|
||||
expect(oidc.consumeState).toHaveBeenCalledWith('st');
|
||||
});
|
||||
|
||||
it('exchangeCodeForToken spreads all arguments through', () => {
|
||||
oidc.exchangeCodeForToken.mockReturnValue({ _ok: true });
|
||||
const doc = { token_endpoint: 'https://idp/token' } as never;
|
||||
expect(s.exchangeCodeForToken(doc, 'code', 'redir', 'cid', 'secret', 'verifier')).toEqual({ _ok: true });
|
||||
expect(oidc.exchangeCodeForToken).toHaveBeenCalledWith(doc, 'code', 'redir', 'cid', 'secret', 'verifier');
|
||||
});
|
||||
|
||||
it('verifyIdToken spreads all arguments through', () => {
|
||||
oidc.verifyIdToken.mockReturnValue({ ok: true });
|
||||
const doc = { issuer: 'https://idp' } as never;
|
||||
expect(s.verifyIdToken('id_token', doc, 'cid', 'https://idp')).toEqual({ ok: true });
|
||||
expect(oidc.verifyIdToken).toHaveBeenCalledWith('id_token', doc, 'cid', 'https://idp');
|
||||
});
|
||||
|
||||
it('getUserInfo forwards the endpoint and access token', () => {
|
||||
oidc.getUserInfo.mockReturnValue({ email: 'a@b.c' });
|
||||
expect(s.getUserInfo('https://idp/ui', 'at')).toEqual({ email: 'a@b.c' });
|
||||
expect(oidc.getUserInfo).toHaveBeenCalledWith('https://idp/ui', 'at');
|
||||
});
|
||||
|
||||
it('findOrCreateUser spreads all arguments through', () => {
|
||||
const result = { user: { id: 1 } };
|
||||
oidc.findOrCreateUser.mockReturnValue(result);
|
||||
const info = { email: 'a@b.c' } as never;
|
||||
const cfg = { issuer: 'https://idp' } as never;
|
||||
expect(s.findOrCreateUser(info, cfg, 'inv')).toBe(result);
|
||||
expect(oidc.findOrCreateUser).toHaveBeenCalledWith(info, cfg, 'inv');
|
||||
});
|
||||
|
||||
it('touchLastLogin forwards the user id', () => {
|
||||
s.touchLastLogin(42);
|
||||
expect(oidc.touchLastLogin).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('generateToken forwards the user', () => {
|
||||
oidc.generateToken.mockReturnValue('jwt');
|
||||
expect(s.generateToken({ id: 7 })).toBe('jwt');
|
||||
expect(oidc.generateToken).toHaveBeenCalledWith({ id: 7 });
|
||||
});
|
||||
|
||||
it('createAuthCode forwards the token', () => {
|
||||
oidc.createAuthCode.mockReturnValue('ac');
|
||||
expect(s.createAuthCode('jwt')).toBe('ac');
|
||||
expect(oidc.createAuthCode).toHaveBeenCalledWith('jwt');
|
||||
});
|
||||
|
||||
it('consumeAuthCode forwards the code', () => {
|
||||
oidc.consumeAuthCode.mockReturnValue({ token: 'jwt' });
|
||||
expect(s.consumeAuthCode('ac')).toEqual({ token: 'jwt' });
|
||||
expect(oidc.consumeAuthCode).toHaveBeenCalledWith('ac');
|
||||
});
|
||||
|
||||
it('frontendUrl forwards the path', () => {
|
||||
oidc.frontendUrl.mockReturnValue('https://app/login');
|
||||
expect(s.frontendUrl('/login')).toBe('https://app/login');
|
||||
expect(oidc.frontendUrl).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('setAuthCookie forwards res, token and req to the cookie helper', () => {
|
||||
const res = {} as Response;
|
||||
const req = {} as Request;
|
||||
s.setAuthCookie(res, 'jwt', req);
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'jwt', req);
|
||||
});
|
||||
});
|
||||
@@ -67,14 +67,35 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
});
|
||||
});
|
||||
|
||||
it('GET / lists items for the trip (success path)', () => {
|
||||
const listItems = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
|
||||
const svc = makeService({ listItems } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] });
|
||||
expect(listItems).toHaveBeenCalledWith('5');
|
||||
});
|
||||
|
||||
describe('POST /import', () => {
|
||||
it('400 when items is not a non-empty array', () => {
|
||||
it('400 when items is not a non-empty array (empty array)', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new PackingController(svc).importItems(user, '5', []))).toEqual({
|
||||
status: 400, body: { error: 'items must be a non-empty array' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 when items is not an array at all (non-array branch)', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new PackingController(svc).importItems(user, '5', 'nope'))).toEqual({
|
||||
status: 400, body: { error: 'items must be a non-empty array' },
|
||||
});
|
||||
});
|
||||
|
||||
it('403 without packing_edit permission', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new PackingController(svc).importItems(user, '5', [{ name: 'a' }]))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('imports and broadcasts per item', () => {
|
||||
const bulkImport = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
|
||||
const broadcast = vi.fn();
|
||||
@@ -103,7 +124,46 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /reorder', () => {
|
||||
it('reorders the items and reports success', () => {
|
||||
const reorderItems = vi.fn();
|
||||
const svc = makeService({ reorderItems } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).reorder(user, '5', [3, 1, 2])).toEqual({ success: true });
|
||||
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1, 2]);
|
||||
});
|
||||
|
||||
it('403 without packing_edit permission', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new PackingController(svc).reorder(user, '5', [1]))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id (remove)', () => {
|
||||
it('404 when the item is missing', () => {
|
||||
const svc = makeService({ deleteItem: vi.fn().mockReturnValue(false) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).remove(user, '5', '9'))).toEqual({
|
||||
status: 404, body: { error: 'Item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes the item and broadcasts', () => {
|
||||
const deleteItem = vi.fn().mockReturnValue(true);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ deleteItem, broadcast } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bags', () => {
|
||||
it('GET /bags lists bags for the trip', () => {
|
||||
const listBags = vi.fn().mockReturnValue([{ id: 3, name: 'Carry-on' }]);
|
||||
const svc = makeService({ listBags } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).listBags(user, '5')).toEqual({ bags: [{ id: 3, name: 'Carry-on' }] });
|
||||
});
|
||||
|
||||
it('400 on bag create with blank name', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({
|
||||
@@ -111,12 +171,77 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
});
|
||||
});
|
||||
|
||||
it('400 on bag create with no name at all (optional-chain short-circuit)', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new PackingController(svc).createBag(user, '5', {}))).toEqual({
|
||||
status: 400, body: { error: 'Name is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a bag and broadcasts', () => {
|
||||
const createBag = vi.fn().mockReturnValue({ id: 3, name: 'Carry-on' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createBag, broadcast } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).createBag(user, '5', { name: 'Carry-on', color: '#fff' }, 'sock')).toEqual({
|
||||
bag: { id: 3, name: 'Carry-on' },
|
||||
});
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-created', { bag: { id: 3, name: 'Carry-on' } }, 'sock');
|
||||
});
|
||||
|
||||
it('404 on bag update when missing', () => {
|
||||
const svc = makeService({ updateBag: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({
|
||||
status: 404, body: { error: 'Bag not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a bag, forwards changed keys and broadcasts', () => {
|
||||
const updateBag = vi.fn().mockReturnValue({ id: 3, name: 'X' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateBag, broadcast } as Partial<PackingService>);
|
||||
new PackingController(svc).updateBag(user, '5', '3', { name: 'X', color: '#000' }, 'sock');
|
||||
expect(updateBag).toHaveBeenCalledWith('5', '3', expect.objectContaining({ name: 'X', color: '#000' }), ['name', 'color']);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-updated', { bag: { id: 3, name: 'X' } }, 'sock');
|
||||
});
|
||||
|
||||
it('404 on bag delete when missing', () => {
|
||||
const svc = makeService({ deleteBag: vi.fn().mockReturnValue(false) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).deleteBag(user, '5', '3'))).toEqual({
|
||||
status: 404, body: { error: 'Bag not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a bag and broadcasts', () => {
|
||||
const deleteBag = vi.fn().mockReturnValue(true);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ deleteBag, broadcast } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).deleteBag(user, '5', '3', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-deleted', { bagId: 3 }, 'sock');
|
||||
});
|
||||
|
||||
it('404 on set-members when the bag is missing', () => {
|
||||
const svc = makeService({ setBagMembers: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).setBagMembers(user, '5', '3', [1, 2]))).toEqual({
|
||||
status: 404, body: { error: 'Bag not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sets bag members and broadcasts (array branch)', () => {
|
||||
const setBagMembers = vi.fn().mockReturnValue([{ user_id: 1 }, { user_id: 2 }]);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ setBagMembers, broadcast } as Partial<PackingService>);
|
||||
const res = new PackingController(svc).setBagMembers(user, '5', '3', [1, 2], 'sock');
|
||||
expect(res).toEqual({ members: [{ user_id: 1 }, { user_id: 2 }] });
|
||||
expect(setBagMembers).toHaveBeenCalledWith('5', '3', [1, 2]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-members-updated', { bagId: 3, members: [{ user_id: 1 }, { user_id: 2 }] }, 'sock');
|
||||
});
|
||||
|
||||
it('coerces non-array members to an empty list (ternary else branch)', () => {
|
||||
const setBagMembers = vi.fn().mockReturnValue([]);
|
||||
const svc = makeService({ setBagMembers } as Partial<PackingService>);
|
||||
new PackingController(svc).setBagMembers(user, '5', '3', 'not-an-array');
|
||||
expect(setBagMembers).toHaveBeenCalledWith('5', '3', []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('templates', () => {
|
||||
@@ -135,6 +260,33 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a template, broadcasts the added items and reports the count', () => {
|
||||
const applyTemplate = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ applyTemplate, broadcast } as Partial<PackingService>);
|
||||
const res = new PackingController(svc).applyTemplate(user, '5', 't1', 'sock');
|
||||
expect(res).toEqual({ items: [{ id: 1 }, { id: 2 }, { id: 3 }], count: 3 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:template-applied', { items: [{ id: 1 }, { id: 2 }, { id: 3 }] }, 'sock');
|
||||
});
|
||||
|
||||
it('400 when an admin saves a template with no name (whitespace)', () => {
|
||||
const saveAsTemplate = vi.fn();
|
||||
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', ' '))).toEqual({
|
||||
status: 400, body: { error: 'Template name is required' },
|
||||
});
|
||||
expect(saveAsTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 when an admin saves a template with no name at all (optional-chain)', () => {
|
||||
const saveAsTemplate = vi.fn();
|
||||
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5'))).toEqual({
|
||||
status: 400, body: { error: 'Template name is required' },
|
||||
});
|
||||
expect(saveAsTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 when a non-admin tries to save a template', () => {
|
||||
const saveAsTemplate = vi.fn();
|
||||
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
|
||||
@@ -162,6 +314,24 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
});
|
||||
|
||||
describe('category assignees', () => {
|
||||
it('GET /category-assignees returns the assignee list for an accessible trip', () => {
|
||||
const getCategoryAssignees = vi.fn().mockReturnValue([{ category: 'Clothes', user_id: 2 }]);
|
||||
const svc = makeService({ getCategoryAssignees } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).categoryAssignees(user, '5')).toEqual({
|
||||
assignees: [{ category: 'Clothes', user_id: 2 }],
|
||||
});
|
||||
expect(getCategoryAssignees).toHaveBeenCalledWith('5');
|
||||
});
|
||||
|
||||
it('decodes the URI-encoded category name before forwarding', () => {
|
||||
const updateCategoryAssignees = vi.fn().mockReturnValue([]);
|
||||
const broadcast = vi.fn();
|
||||
const notifyTagged = vi.fn();
|
||||
const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial<PackingService>);
|
||||
new PackingController(svc).updateCategoryAssignees(user, '5', 'Toys%20%26%20Games', [2]);
|
||||
expect(updateCategoryAssignees).toHaveBeenCalledWith('5', 'Toys & Games', [2]);
|
||||
});
|
||||
|
||||
it('updates assignees, broadcasts and fires the tag notification', () => {
|
||||
const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]);
|
||||
const broadcast = vi.fn();
|
||||
|
||||
@@ -16,12 +16,15 @@ const { pk } = vi.hoisted(() => ({
|
||||
pk: {
|
||||
verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(), deleteItem: vi.fn(),
|
||||
bulkImport: vi.fn(), listBags: vi.fn(), createBag: vi.fn(), updateBag: vi.fn(), deleteBag: vi.fn(),
|
||||
applyTemplate: vi.fn(), saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(),
|
||||
listTemplates: vi.fn(), applyTemplate: vi.fn(), saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(),
|
||||
updateCategoryAssignees: vi.fn(), reorderItems: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/packingService', () => pk);
|
||||
|
||||
const { send } = vi.hoisted(() => ({ send: vi.fn(() => Promise.resolve()) }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send }));
|
||||
|
||||
import { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
|
||||
function svc() {
|
||||
@@ -55,6 +58,7 @@ describe('PackingService (wrapper delegation + helpers)', () => {
|
||||
s.updateBag('5', '2', { name: 'B' } as never, ['name']); expect(pk.updateBag).toHaveBeenCalledWith('5', '2', { name: 'B' }, ['name']);
|
||||
s.deleteBag('5', '2'); expect(pk.deleteBag).toHaveBeenCalledWith('5', '2');
|
||||
s.setBagMembers('5', '2', [1, 2]); expect(pk.setBagMembers).toHaveBeenCalledWith('5', '2', [1, 2]);
|
||||
s.listTemplates(); expect(pk.listTemplates).toHaveBeenCalled();
|
||||
s.applyTemplate('5', 't1'); expect(pk.applyTemplate).toHaveBeenCalledWith('5', 't1');
|
||||
s.saveAsTemplate('5', 1, 'Tpl'); expect(pk.saveAsTemplate).toHaveBeenCalledWith('5', 1, 'Tpl');
|
||||
s.getCategoryAssignees('5'); expect(pk.getCategoryAssignees).toHaveBeenCalledWith('5');
|
||||
@@ -71,5 +75,31 @@ describe('PackingService (wrapper delegation + helpers)', () => {
|
||||
it('fires the notification when users are tagged (fire-and-forget, no throw)', () => {
|
||||
expect(() => svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3])).not.toThrow();
|
||||
});
|
||||
|
||||
it('queries the trip title and dispatches the notification with the resolved title', async () => {
|
||||
dbMock._stmt.get.mockReturnValue({ title: 'Iceland 2026' });
|
||||
svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3]);
|
||||
// Flush the dynamic import().then microtask chain.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(dbMock.prepare).toHaveBeenCalledWith('SELECT title FROM trips WHERE id = ?');
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: 'packing_tagged',
|
||||
actorId: 1,
|
||||
scope: 'trip',
|
||||
targetId: 5,
|
||||
params: expect.objectContaining({ trip: 'Iceland 2026', actor: 'a@b.c', category: 'Clothes', tripId: '5' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to "Untitled" when the trip row is missing (?? / default branch)', async () => {
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ params: expect.objectContaining({ trip: 'Untitled' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,10 +85,63 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /import/map', () => {
|
||||
const file = { buffer: Buffer.from('<kml/>'), originalname: 'm.kml' } as Express.Multer.File;
|
||||
it('400 without a file', async () => {
|
||||
expect(await thrownAsync(() => new PlacesController(svc()).importMap(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
});
|
||||
it('403 without place_edit (permission runs before the file check)', async () => {
|
||||
const importMapFile = vi.fn();
|
||||
const s = svc({ canEdit: vi.fn().mockReturnValue(false), importMapFile } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(importMapFile).not.toHaveBeenCalled();
|
||||
});
|
||||
it('400 when both import types are disabled', async () => {
|
||||
expect(await thrownAsync(() => new PlacesController(svc()).importMap(user, '5', file, { importPoints: 'false', importPaths: 'false' }))).toEqual({
|
||||
status: 400, body: { error: 'No import types selected' },
|
||||
});
|
||||
});
|
||||
it('400 when the map file has no Placemarks (and carries the summary through)', async () => {
|
||||
const summary = { totalPlacemarks: 0 };
|
||||
const s = svc({ importMapFile: vi.fn().mockResolvedValue({ places: [], summary }) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({
|
||||
status: 400, body: { error: 'No valid Placemarks found in map file', summary },
|
||||
});
|
||||
});
|
||||
it('imports, broadcasts per place + returns the service result', async () => {
|
||||
const broadcast = vi.fn();
|
||||
const result = { places: [{ id: 1 }, { id: 2 }], summary: { totalPlacemarks: 2 }, count: 2 };
|
||||
const s = svc({ importMapFile: vi.fn().mockResolvedValue(result), broadcast } as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s).importMap(user, '5', file, {}, 'sock')).toEqual(result);
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'place:created', { place: { id: 1 } }, 'sock');
|
||||
});
|
||||
it('passes a missing summary through (no zero-placemark guard) and still imports', async () => {
|
||||
const result = { places: [{ id: 7 }] };
|
||||
const s = svc({ importMapFile: vi.fn().mockResolvedValue(result), broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s).importMap(user, '5', file, {})).toEqual(result);
|
||||
});
|
||||
it('wraps a thrown Error from the service in a 400 with its message', async () => {
|
||||
const s = svc({ importMapFile: vi.fn().mockRejectedValue(new Error('bad kml')) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'bad kml' } });
|
||||
});
|
||||
it('falls back to a generic 400 message for a non-Error rejection', async () => {
|
||||
const s = svc({ importMapFile: vi.fn().mockRejectedValue('boom') } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'Failed to import map file' } });
|
||||
});
|
||||
it('re-throws an HttpException raised inside the try untouched', async () => {
|
||||
const s = svc({ importMapFile: vi.fn().mockRejectedValue(new HttpException({ error: 'teapot' }, 418)) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importMap(user, '5', file, {}))).toEqual({ status: 418, body: { error: 'teapot' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /import/google-list + naver-list', () => {
|
||||
it('400 without a url', async () => {
|
||||
expect(await thrownAsync(() => new PlacesController(svc()).importGoogle(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
});
|
||||
it('400 when url is the wrong type (not a string)', async () => {
|
||||
expect(await thrownAsync(() => new PlacesController(svc()).importNaver(user, '5', 123))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
});
|
||||
it('maps a service { error, status } to the same response', async () => {
|
||||
const s = svc({ importGoogleList: vi.fn().mockResolvedValue({ error: 'List is empty', status: 400 }) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'List is empty' } });
|
||||
@@ -97,6 +150,26 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
const s = svc({ importNaverList: vi.fn().mockResolvedValue({ places: [{ id: 1 }], listName: 'Trip', skipped: 2 }), broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s).importNaver(user, '5', 'http://x')).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 });
|
||||
});
|
||||
it('forwards the enrich flag + userId and broadcasts each imported place', async () => {
|
||||
const importGoogleList = vi.fn().mockResolvedValue({ places: [{ id: 1 }, { id: 2 }], listName: 'L', skipped: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ importGoogleList, broadcast } as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s).importGoogle(user, '5', 'http://x', 'true', 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, listName: 'L', skipped: 0 });
|
||||
expect(importGoogleList).toHaveBeenCalledWith('5', 'http://x', { enrich: true, userId: 1 });
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('wraps a thrown Error in the provider-specific 400 (Google)', async () => {
|
||||
const s = svc({ importGoogleList: vi.fn().mockRejectedValue(new Error('network down')) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' },
|
||||
});
|
||||
});
|
||||
it('wraps a non-Error rejection in the provider-specific 400 (Naver)', async () => {
|
||||
const s = svc({ importNaverList: vi.fn().mockRejectedValue('weird') } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importNaver(user, '5', 'http://x'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /bulk-delete', () => {
|
||||
@@ -117,8 +190,10 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /:id 404 when missing', () => {
|
||||
it('GET /:id returns the place when found, 404 when missing', () => {
|
||||
expect(thrown(() => new PlacesController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<PlacesService>)).get(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial<PlacesService>);
|
||||
expect(new PlacesController(s).get(user, '5', '9')).toEqual({ place: { id: 9 } });
|
||||
});
|
||||
|
||||
it('PUT /:id 404 when missing, else updates + hooks', () => {
|
||||
@@ -143,4 +218,11 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
const e = svc({ searchImage: vi.fn().mockResolvedValue({ error: 'No key', status: 400 }) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(e).image(user, '5', '9'))).toEqual({ status: 400, body: { error: 'No key' } });
|
||||
});
|
||||
|
||||
it('GET /:id/image turns an unexpected throw into a 500, but re-throws an HttpException as-is', async () => {
|
||||
const boom = svc({ searchImage: vi.fn().mockRejectedValue(new Error('Unsplash down')) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(boom).image(user, '5', '9'))).toEqual({ status: 500, body: { error: 'Error searching for image' } });
|
||||
const http = svc({ searchImage: vi.fn().mockRejectedValue(new HttpException({ error: 'rate limited' }, 429)) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(http).image(user, '5', '9'))).toEqual({ status: 429, body: { error: 'rate limited' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
// --- hoisted mock fns so the vi.mock factories can reference them -----------------
|
||||
const h = vi.hoisted(() => ({
|
||||
verifyJwtAndLoadUser: vi.fn(),
|
||||
isAddonEnabled: vi.fn(),
|
||||
getMcpSafeUrl: vi.fn(() => 'https://trek.example.test'),
|
||||
dbPrepare: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
// SDK middleware spies — each returns a tagged handler so we can identify which
|
||||
// app.use call received it.
|
||||
metaRouter: vi.fn(),
|
||||
authorizeHandler: vi.fn(),
|
||||
registerHandler: vi.fn(),
|
||||
mcpHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/middleware/auth', () => ({ verifyJwtAndLoadUser: h.verifyJwtAndLoadUser }));
|
||||
vi.mock('../../../src/db/database', () => ({ db: { prepare: h.dbPrepare } }));
|
||||
vi.mock('../../../src/mcp', () => ({ mcpHandler: h.mcpHandler }));
|
||||
vi.mock('../../../src/mcp/oauthProvider', () => ({ trekOAuthProvider: {}, trekClientsStore: {} }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: h.isAddonEnabled }));
|
||||
vi.mock('../../../src/services/notifications', () => ({ getMcpSafeUrl: h.getMcpSafeUrl }));
|
||||
|
||||
// SDK router/handler factories return distinct tagged middleware so we never hit
|
||||
// real new URL(...) wiring during registration.
|
||||
vi.mock('@modelcontextprotocol/sdk/server/auth/router', () => ({
|
||||
mcpAuthMetadataRouter: vi.fn(() => h.metaRouter),
|
||||
}));
|
||||
vi.mock('@modelcontextprotocol/sdk/server/auth/handlers/authorize', () => ({
|
||||
authorizationHandler: vi.fn(() => h.authorizeHandler),
|
||||
}));
|
||||
vi.mock('@modelcontextprotocol/sdk/server/auth/handlers/register', () => ({
|
||||
clientRegistrationHandler: vi.fn(() => h.registerHandler),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (orig) => {
|
||||
const real = (await orig()) as Record<string, unknown>;
|
||||
return { ...real, default: { ...(real.default as object), existsSync: h.existsSync }, existsSync: h.existsSync };
|
||||
});
|
||||
|
||||
import {
|
||||
applyPlatformUploads,
|
||||
applyPlatformTransport,
|
||||
applyPlatformSpa,
|
||||
applyPlatformStatic,
|
||||
} from '../../../src/nest/platform/platform.routes';
|
||||
import { SpaFallbackFilter } from '../../../src/nest/platform/spa-fallback.filter';
|
||||
|
||||
// Tagged sentinel for express.static — we only need to know it was registered on
|
||||
// the right path, not run it.
|
||||
vi.mock('express', async () => {
|
||||
const staticFn = vi.fn(() => 'STATIC' as unknown);
|
||||
const fn: unknown = () => ({});
|
||||
Object.assign(fn as object, { static: staticFn });
|
||||
return { default: fn, static: staticFn };
|
||||
});
|
||||
|
||||
type Handler = (...args: unknown[]) => unknown;
|
||||
|
||||
/**
|
||||
* A fake express.Application that records every route/middleware registration so
|
||||
* individual handlers can be pulled out and exercised in isolation.
|
||||
*/
|
||||
function fakeApp() {
|
||||
const calls: Array<{ method: string; path?: string; handlers: Handler[] }> = [];
|
||||
const record = (method: string) => (...args: unknown[]) => {
|
||||
if (typeof args[0] === 'string' || args[0] instanceof RegExp) {
|
||||
calls.push({ method, path: String(args[0]), handlers: args.slice(1) as Handler[] });
|
||||
} else {
|
||||
calls.push({ method, handlers: args as Handler[] });
|
||||
}
|
||||
};
|
||||
const app = {
|
||||
use: record('use'),
|
||||
get: record('get'),
|
||||
post: record('post'),
|
||||
delete: record('delete'),
|
||||
} as never;
|
||||
return { app, calls };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
headers: {} as Record<string, string>,
|
||||
status: vi.fn(function (this: typeof res, c: number) { this.statusCode = c; return this; }),
|
||||
json: vi.fn(function (this: typeof res, b: unknown) { this.body = b; return this; }),
|
||||
send: vi.fn(function (this: typeof res, b: unknown) { this.body = b; return this; }),
|
||||
end: vi.fn(function (this: typeof res) { return this; }),
|
||||
sendFile: vi.fn(function (this: typeof res, p: string) { this.body = `FILE:${p}`; return this; }),
|
||||
setHeader: vi.fn(function (this: typeof res, k: string, v: string) { this.headers[k] = v; return this; }),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.getMcpSafeUrl.mockReturnValue('https://trek.example.test');
|
||||
});
|
||||
|
||||
describe('applyPlatformUploads', () => {
|
||||
it('registers the static avatar/cover/journey mounts + the files block', () => {
|
||||
const { app, calls } = fakeApp();
|
||||
applyPlatformUploads(app);
|
||||
const paths = calls.filter((c) => c.method === 'use').map((c) => c.path);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining(['/uploads/avatars', '/uploads/covers', '/uploads/journey', '/uploads/files']),
|
||||
);
|
||||
});
|
||||
|
||||
it('the /uploads/files block always answers 401', () => {
|
||||
const { app, calls } = fakeApp();
|
||||
applyPlatformUploads(app);
|
||||
const filesBlock = calls.find((c) => c.path === '/uploads/files')!.handlers[0];
|
||||
const res = makeRes();
|
||||
filesBlock({}, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toBe('Authentication required');
|
||||
});
|
||||
|
||||
describe('GET /uploads/photos/:filename', () => {
|
||||
function photoHandler() {
|
||||
const { app, calls } = fakeApp();
|
||||
applyPlatformUploads(app);
|
||||
return calls.find((c) => c.method === 'get' && c.path === '/uploads/photos/:filename')!.handlers[0];
|
||||
}
|
||||
|
||||
it('403 when the resolved path escapes the photos dir', () => {
|
||||
// basename() strips the traversal, but feed a name that resolves outside by
|
||||
// stubbing path indirectly is hard — instead exercise the existsSync 404 etc.
|
||||
// The startsWith guard is defensive; cover it via a filename of '..'.
|
||||
const handler = photoHandler();
|
||||
const res = makeRes();
|
||||
// path.basename('..') === '..' -> join(photos,'..') resolves to uploads -> not under photos
|
||||
handler({ params: { filename: '..' }, headers: {}, query: {} }, res);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('404 when the file does not exist', () => {
|
||||
h.existsSync.mockReturnValue(false);
|
||||
const res = makeRes();
|
||||
photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: {} }, res);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toBe('Not found');
|
||||
});
|
||||
|
||||
it('401 when no token is supplied', () => {
|
||||
h.existsSync.mockReturnValue(true);
|
||||
const res = makeRes();
|
||||
photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: {} }, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('serves the file for a valid JWT session (Bearer header)', () => {
|
||||
h.existsSync.mockReturnValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 });
|
||||
const res = makeRes();
|
||||
photoHandler()(
|
||||
{ params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer jwt123' }, query: {} },
|
||||
res,
|
||||
);
|
||||
expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith('jwt123');
|
||||
expect(String(res.body)).toContain('FILE:');
|
||||
});
|
||||
|
||||
it('reads the token from the query string when there is no Bearer header', () => {
|
||||
h.existsSync.mockReturnValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 });
|
||||
const res = makeRes();
|
||||
photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'qtok' } }, res);
|
||||
expect(h.verifyJwtAndLoadUser).toHaveBeenCalledWith('qtok');
|
||||
expect(String(res.body)).toContain('FILE:');
|
||||
});
|
||||
|
||||
it('401 when the token is not a session and the photo row is missing', () => {
|
||||
h.existsSync.mockReturnValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue(undefined) });
|
||||
const res = makeRes();
|
||||
photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share1' } }, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('401 when a share token does not cover the photo trip', () => {
|
||||
h.existsSync.mockReturnValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
const photoStmt = { get: vi.fn().mockReturnValue({ trip_id: 7 }) };
|
||||
const shareStmt = { get: vi.fn().mockReturnValue({ trip_id: 8 }) };
|
||||
h.dbPrepare.mockImplementationOnce(() => photoStmt).mockImplementationOnce(() => shareStmt);
|
||||
const res = makeRes();
|
||||
photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share1' } }, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('401 when there is no matching share token at all', () => {
|
||||
h.existsSync.mockReturnValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
const photoStmt = { get: vi.fn().mockReturnValue({ trip_id: 7 }) };
|
||||
const shareStmt = { get: vi.fn().mockReturnValue(undefined) };
|
||||
h.dbPrepare.mockImplementationOnce(() => photoStmt).mockImplementationOnce(() => shareStmt);
|
||||
const res = makeRes();
|
||||
photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share1' } }, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('serves the file when the share token covers the photo trip', () => {
|
||||
h.existsSync.mockReturnValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
const photoStmt = { get: vi.fn().mockReturnValue({ trip_id: 7 }) };
|
||||
const shareStmt = { get: vi.fn().mockReturnValue({ trip_id: 7 }) };
|
||||
h.dbPrepare.mockImplementationOnce(() => photoStmt).mockImplementationOnce(() => shareStmt);
|
||||
const res = makeRes();
|
||||
photoHandler()(
|
||||
{ params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer share1' }, query: {} },
|
||||
res,
|
||||
);
|
||||
expect(String(res.body)).toContain('FILE:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPlatformTransport', () => {
|
||||
function build() {
|
||||
const { app, calls } = fakeApp();
|
||||
applyPlatformTransport(app);
|
||||
return calls;
|
||||
}
|
||||
|
||||
it('GET /api/health sets no-store and returns ok', () => {
|
||||
const calls = build();
|
||||
const health = calls.find((c) => c.method === 'get' && c.path === '/api/health')!.handlers[0];
|
||||
const res = makeRes();
|
||||
health({}, res);
|
||||
expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate');
|
||||
expect(res.body).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
describe('the /.well-known metadata middleware', () => {
|
||||
function wellKnownMw(calls: ReturnType<typeof build>) {
|
||||
// first app.use with no path, registered right after /api/health
|
||||
return calls.find((c) => c.method === 'use' && c.path === undefined)!.handlers[0];
|
||||
}
|
||||
|
||||
it('404s a /.well-known path when MCP is disabled', () => {
|
||||
h.isAddonEnabled.mockReturnValue(false);
|
||||
const mw = wellKnownMw(build());
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({ path: '/.well-known/oauth-authorization-server' }, res, next);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates to the SDK meta router for a non-well-known path', () => {
|
||||
h.isAddonEnabled.mockReturnValue(true);
|
||||
const mw = wellKnownMw(build());
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({ path: '/anything' }, res, next);
|
||||
expect(h.metaRouter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates to the SDK meta router for a well-known path when MCP is enabled', () => {
|
||||
h.isAddonEnabled.mockReturnValue(true);
|
||||
const mw = wellKnownMw(build());
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({ path: '/.well-known/oauth-authorization-server' }, res, next);
|
||||
expect(h.metaRouter).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /.well-known/openid-configuration returns AS metadata + userinfo_endpoint', () => {
|
||||
const calls = build();
|
||||
const handler = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0];
|
||||
const res = makeRes();
|
||||
handler({}, res);
|
||||
const body = res.body as { issuer: string; userinfo_endpoint: string };
|
||||
expect(body.issuer).toBe('https://trek.example.test');
|
||||
expect(body.userinfo_endpoint).toBe('https://trek.example.test/oauth/userinfo');
|
||||
});
|
||||
|
||||
it('trims trailing slashes off the configured base URL', () => {
|
||||
h.getMcpSafeUrl.mockReturnValue('https://trek.example.test///');
|
||||
const calls = build();
|
||||
const handler = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0];
|
||||
const res = makeRes();
|
||||
handler({}, res);
|
||||
expect((res.body as { issuer: string }).issuer).toBe('https://trek.example.test');
|
||||
});
|
||||
|
||||
describe('GET /.well-known/oauth-protected-resource (flat)', () => {
|
||||
function handler() {
|
||||
return build().find((c) => c.method === 'get' && c.path === '/.well-known/oauth-protected-resource')!.handlers[0];
|
||||
}
|
||||
|
||||
it('404 when MCP is disabled', () => {
|
||||
h.isAddonEnabled.mockReturnValue(false);
|
||||
const res = makeRes();
|
||||
handler()({}, res);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns the PRM document when MCP is enabled', () => {
|
||||
h.isAddonEnabled.mockReturnValue(true);
|
||||
const res = makeRes();
|
||||
handler()({}, res);
|
||||
const body = res.body as { resource: string; authorization_servers: string[] };
|
||||
expect(body.resource).toBe('https://trek.example.test/mcp');
|
||||
expect(body.authorization_servers).toEqual(['https://trek.example.test']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mcpAddonGate (used on /oauth/authorize + /oauth/register)', () => {
|
||||
function gate() {
|
||||
// The gate is the first handler on the /oauth/authorize use registration.
|
||||
return build().find((c) => c.method === 'use' && c.path === '/oauth/authorize')!.handlers[0];
|
||||
}
|
||||
|
||||
it('404 when MCP is disabled', () => {
|
||||
h.isAddonEnabled.mockReturnValue(false);
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
gate()({}, res, next);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() when MCP is enabled', () => {
|
||||
h.isAddonEnabled.mockReturnValue(true);
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
gate()({}, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('wires the SDK authorize + register handlers behind the gate', () => {
|
||||
const calls = build();
|
||||
const authorize = calls.find((c) => c.path === '/oauth/authorize')!;
|
||||
const register = calls.find((c) => c.path === '/oauth/register')!;
|
||||
expect(authorize.handlers).toContain(h.authorizeHandler);
|
||||
expect(register.handlers).toContain(h.registerHandler);
|
||||
});
|
||||
|
||||
it('mounts the MCP handler on POST/GET/DELETE /mcp', () => {
|
||||
const calls = build();
|
||||
expect(calls.find((c) => c.method === 'post' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler);
|
||||
expect(calls.find((c) => c.method === 'get' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler);
|
||||
expect(calls.find((c) => c.method === 'delete' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler);
|
||||
});
|
||||
|
||||
describe('the terminal /.well-known JSON-404 middleware', () => {
|
||||
function mw() {
|
||||
// The pathless app.use registered after the /mcp routes.
|
||||
const calls = build();
|
||||
const pathless = calls.filter((c) => c.method === 'use' && c.path === undefined);
|
||||
// first pathless = meta router; second = the JSON 404.
|
||||
return pathless[1].handlers[0];
|
||||
}
|
||||
|
||||
it('404 JSON for an unhandled /.well-known path', () => {
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw()({ path: '/.well-known/unknown' }, res, next);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'not_found' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() for any non-well-known path', () => {
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw()({ path: '/dashboard' }, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('the /oauth/consent middleware relaxes COOP then continues', () => {
|
||||
const calls = build();
|
||||
const mw = calls.find((c) => c.method === 'use' && c.path === '/oauth/consent')!.handlers[0];
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({}, res, next);
|
||||
expect(res.headers['Cross-Origin-Opener-Policy']).toBe('unsafe-none');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caches the OAuth metadata + SDK router across requests (lazy init runs once)', async () => {
|
||||
const router = await import('@modelcontextprotocol/sdk/server/auth/router');
|
||||
const calls = build();
|
||||
const openid = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0];
|
||||
h.getMcpSafeUrl.mockClear();
|
||||
openid({}, makeRes());
|
||||
openid({}, makeRes());
|
||||
// getMcpSafeUrl is only consulted on the first lazy build of the metadata.
|
||||
expect(h.getMcpSafeUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Trigger the meta router lazy build twice; the SDK factory runs once.
|
||||
const metaMw = calls.find((c) => c.method === 'use' && c.path === undefined)!.handlers[0];
|
||||
h.isAddonEnabled.mockReturnValue(true);
|
||||
metaMw({ path: '/x' }, makeRes(), vi.fn());
|
||||
metaMw({ path: '/y' }, makeRes(), vi.fn());
|
||||
expect(router.mcpAuthMetadataRouter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPlatformStatic', () => {
|
||||
const original = process.env.NODE_ENV;
|
||||
afterEach(() => { process.env.NODE_ENV = original; });
|
||||
|
||||
it('is a no-op outside production', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const { app, calls } = fakeApp();
|
||||
applyPlatformStatic(app);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('serves the built client statics in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const { app, calls } = fakeApp();
|
||||
applyPlatformStatic(app);
|
||||
expect(calls.some((c) => c.method === 'use')).toBe(true);
|
||||
});
|
||||
|
||||
it('the static setHeaders callback adds no-cache for index.html only', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const expressMod = (await import('express')).default as unknown as { static: ReturnType<typeof vi.fn> };
|
||||
expressMod.static.mockClear();
|
||||
const { app } = fakeApp();
|
||||
applyPlatformStatic(app);
|
||||
const opts = expressMod.static.mock.calls[0][1] as { setHeaders: (res: unknown, p: string) => void };
|
||||
const indexRes = makeRes();
|
||||
opts.setHeaders(indexRes, '/some/index.html');
|
||||
expect(indexRes.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
|
||||
const assetRes = makeRes();
|
||||
opts.setHeaders(assetRes, '/some/app.js');
|
||||
expect(assetRes.headers['Cache-Control']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPlatformSpa', () => {
|
||||
const original = process.env.NODE_ENV;
|
||||
afterEach(() => { process.env.NODE_ENV = original; });
|
||||
|
||||
it('only serves statics (no catch-all) outside production', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const { app, calls } = fakeApp();
|
||||
applyPlatformSpa(app);
|
||||
expect(calls.some((c) => c.method === 'get' && c.path === '/.*/' )).toBe(false);
|
||||
});
|
||||
|
||||
it('registers the index.html catch-all in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const { app, calls } = fakeApp();
|
||||
applyPlatformSpa(app);
|
||||
const catchAll = calls.find((c) => c.method === 'get');
|
||||
expect(catchAll).toBeDefined();
|
||||
const res = makeRes();
|
||||
catchAll!.handlers[0]({}, res);
|
||||
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
|
||||
expect(String(res.body)).toContain('FILE:');
|
||||
expect(String(res.body)).toContain('index.html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpaFallbackFilter', () => {
|
||||
const original = process.env.NODE_ENV;
|
||||
afterEach(() => { process.env.NODE_ENV = original; });
|
||||
|
||||
function host(req: { method: string }, res: ReturnType<typeof makeRes>) {
|
||||
return { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }) } as never;
|
||||
}
|
||||
|
||||
it('serves index.html for an unmatched GET in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const res = makeRes();
|
||||
new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET' }, res));
|
||||
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
|
||||
expect(String(res.body)).toContain('index.html');
|
||||
});
|
||||
|
||||
it('keeps the JSON 404 envelope for a non-GET miss in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const res = makeRes();
|
||||
new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST' }, res));
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'gone' });
|
||||
});
|
||||
|
||||
it('keeps the JSON 404 envelope outside production even for GET', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const res = makeRes();
|
||||
new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET' }, res));
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'missing' });
|
||||
});
|
||||
|
||||
it('falls back to Not Found when the exception has no message', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const res = makeRes();
|
||||
const exc = new NotFoundException();
|
||||
// force an empty message so the || branch is taken
|
||||
Object.defineProperty(exc, 'message', { value: '' });
|
||||
new SpaFallbackFilter().catch(exc, host({ method: 'GET' }, res));
|
||||
expect(res.body).toEqual({ error: 'Not Found' });
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
const { createReadStream } = vi.hoisted(() => ({ createReadStream: vi.fn() }));
|
||||
vi.mock('node:fs', () => ({ createReadStream }));
|
||||
|
||||
import { TripShareController, SharedController } from '../../../src/nest/share/share.controller';
|
||||
import type { ShareService } from '../../../src/nest/share/share.service';
|
||||
import type { User } from '../../../src/types';
|
||||
@@ -69,4 +72,66 @@ describe('SharedController', () => {
|
||||
expect(thrown(() => new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial<ShareService>)).read('bad'))).toEqual({ status: 404, body: { error: 'Invalid or expired link' } });
|
||||
expect(new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial<ShareService>)).read('tok')).toEqual({ trip: { id: 9 } });
|
||||
});
|
||||
|
||||
describe('place-photo proxy', () => {
|
||||
function photoRes() {
|
||||
const r = {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
status: vi.fn(function (this: unknown, c: number) { (r as { statusCode: number }).statusCode = c; return r; }),
|
||||
json: vi.fn(),
|
||||
set: vi.fn(),
|
||||
type: vi.fn(),
|
||||
};
|
||||
return r as unknown as Response & { status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; type: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
beforeEach(() => createReadStream.mockReset());
|
||||
|
||||
function controller(path: string | null) {
|
||||
return new SharedController(svc({ getSharedPlacePhotoPath: vi.fn().mockReturnValue(path) } as Partial<ShareService>));
|
||||
}
|
||||
|
||||
it('404 without streaming when the photo is not cached for the token', () => {
|
||||
const res = photoRes();
|
||||
controller(null).placePhotoBytes('tok', 'p1', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
|
||||
expect(createReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streams the cached file with image/jpeg + an immutable cache header on a hit', () => {
|
||||
const stream = { on: vi.fn().mockReturnThis(), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = photoRes();
|
||||
controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res);
|
||||
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
expect(res.type).toHaveBeenCalledWith('image/jpeg');
|
||||
expect(createReadStream).toHaveBeenCalledWith('/cache/p1.jpg');
|
||||
expect(stream.pipe).toHaveBeenCalledWith(res);
|
||||
});
|
||||
|
||||
it('falls back to 404 when the read stream errors before headers were sent', () => {
|
||||
let onError: () => void = () => {};
|
||||
const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = photoRes();
|
||||
controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res);
|
||||
onError();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
|
||||
});
|
||||
|
||||
it('does not re-send a 404 when the stream errors after headers were flushed', () => {
|
||||
let onError: () => void = () => {};
|
||||
const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = photoRes();
|
||||
(res as { headersSent: boolean }).headersSent = true;
|
||||
controller('/cache/p1.jpg').placePhotoBytes('tok', 'p1', res);
|
||||
onError();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The wrapper delegates to legacy helpers; mock them so no real DB is loaded.
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../../src/db/database', () => ({ canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { share } = vi.hoisted(() => ({
|
||||
share: {
|
||||
createOrUpdateShareLink: vi.fn(),
|
||||
getShareLink: vi.fn(),
|
||||
deleteShareLink: vi.fn(),
|
||||
getSharedTripData: vi.fn(),
|
||||
getSharedPlacePhotoPath: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/shareService', () => share);
|
||||
|
||||
import { ShareService } from '../../../src/nest/share/share.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
function svc() {
|
||||
return new ShareService();
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ShareService', () => {
|
||||
it('verifyTripAccess delegates to canAccessTrip', () => {
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(canAccessTrip).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
|
||||
it('canManage forwards the ownership flag when the user owns the trip', () => {
|
||||
checkPermission.mockReturnValue(true);
|
||||
const trip = { user_id: 1 } as never;
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
expect(svc().canManage(trip, user)).toBe(true);
|
||||
expect(checkPermission).toHaveBeenCalledWith('share_manage', 'user', 1, 1, false);
|
||||
});
|
||||
|
||||
it('canManage marks the user as a guest when they do not own the trip', () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const trip = { user_id: 2 } as never;
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
expect(svc().canManage(trip, user)).toBe(false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('share_manage', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('createOrUpdate delegates to the legacy share service', () => {
|
||||
share.createOrUpdateShareLink.mockReturnValue({ token: 't', created: true });
|
||||
const perms = { share_map: true };
|
||||
expect(svc().createOrUpdate('5', 2, perms)).toEqual({ token: 't', created: true });
|
||||
expect(share.createOrUpdateShareLink).toHaveBeenCalledWith('5', 2, perms);
|
||||
});
|
||||
|
||||
it('get / remove / getSharedTripData / getSharedPlacePhotoPath delegate', () => {
|
||||
share.getShareLink.mockReturnValue({ token: 't' });
|
||||
expect(svc().get('5')).toEqual({ token: 't' });
|
||||
expect(share.getShareLink).toHaveBeenCalledWith('5');
|
||||
|
||||
svc().remove('5');
|
||||
expect(share.deleteShareLink).toHaveBeenCalledWith('5');
|
||||
|
||||
share.getSharedTripData.mockReturnValue({ trip: { id: 9 } });
|
||||
expect(svc().getSharedTripData('tok')).toEqual({ trip: { id: 9 } });
|
||||
expect(share.getSharedTripData).toHaveBeenCalledWith('tok');
|
||||
|
||||
share.getSharedPlacePhotoPath.mockReturnValue('/cache/p1.jpg');
|
||||
expect(svc().getSharedPlacePhotoPath('tok', 'p1')).toBe('/cache/p1.jpg');
|
||||
expect(share.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'p1');
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,12 @@ import { HttpException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() }));
|
||||
vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
const { isDemoEmail } = vi.hoisted(() => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
vi.mock('../../../src/services/demo', () => ({ isDemoEmail }));
|
||||
|
||||
import { TripsController } from '../../../src/nest/trips/trips.controller';
|
||||
import type { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
import { NotFoundError, ValidationError } from '../../../src/services/tripService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
@@ -40,6 +42,15 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
|
||||
expect(list).toHaveBeenCalledWith(1, 1);
|
||||
});
|
||||
|
||||
it('GET / defaults the archived flag to 0 when not "1"', () => {
|
||||
const list = vi.fn().mockReturnValue([]);
|
||||
const c = new TripsController(svc({ list } as Partial<TripsService>));
|
||||
c.list(user, undefined);
|
||||
expect(list).toHaveBeenLastCalledWith(1, 0);
|
||||
c.list(user, '0');
|
||||
expect(list).toHaveBeenLastCalledWith(1, 0);
|
||||
});
|
||||
|
||||
describe('POST / (create)', () => {
|
||||
it('403 without trip_create, 400 without title', () => {
|
||||
expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
|
||||
@@ -57,12 +68,34 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
|
||||
status: 400, body: { error: 'End date must be after start date' },
|
||||
});
|
||||
});
|
||||
|
||||
it('infers start_date from end_date (-6 days) and parses day_count', () => {
|
||||
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
||||
new TripsController(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', end_date: '2026-07-07', day_count: '40' }, req);
|
||||
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07', day_count: 40 }));
|
||||
});
|
||||
|
||||
it('clamps a non-numeric day_count to the default of 7', () => {
|
||||
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
||||
new TripsController(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', day_count: 'abc' }, req);
|
||||
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 }));
|
||||
});
|
||||
|
||||
it('logs the reminder when reminderDays is set', () => {
|
||||
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 });
|
||||
expect(new TripsController(svc({ create } as Partial<TripsService>)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } });
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /:id 404 when missing', () => {
|
||||
expect(thrown(() => new TripsController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('GET /:id returns the trip when present', () => {
|
||||
const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial<TripsService>);
|
||||
expect(new TripsController(s).get(user, '9')).toEqual({ trip: { id: 9 } });
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when no access; 403 on archive without trip_archive', () => {
|
||||
expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).update(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
@@ -77,6 +110,45 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
|
||||
expect(new TripsController(s).update(user, '9', { title: 'b' }, req, 'sock')).toEqual({ trip: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9 } }, 'sock');
|
||||
});
|
||||
|
||||
it('403 on cover_image without trip_cover_upload', () => {
|
||||
const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_cover_upload') });
|
||||
expect(thrown(() => new TripsController(s).update(user, '9', { cover_image: '/x.jpg' }, req))).toEqual({ status: 403, body: { error: 'No permission to change cover image' } });
|
||||
});
|
||||
|
||||
it('403 on an edit field without trip_edit', () => {
|
||||
const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_edit') });
|
||||
expect(thrown(() => new TripsController(s).update(user, '9', { title: 'b' }, req))).toEqual({ status: 403, body: { error: 'No permission to edit this trip' } });
|
||||
});
|
||||
|
||||
it('admin edit logs the owner and reminder changes', () => {
|
||||
const update = vi.fn().mockReturnValue({
|
||||
updatedTrip: { id: 9 }, changes: { title: { oldValue: 'a', newValue: 'b' } }, newTitle: 'b',
|
||||
ownerEmail: 'owner@x.y', isAdminEdit: true, newReminder: 5, oldReminder: 0,
|
||||
});
|
||||
const s = svc({ update } as Partial<TripsService>);
|
||||
expect(new TripsController(s).update(user, '9', { title: 'b' }, req)).toEqual({ trip: { id: 9 } });
|
||||
});
|
||||
|
||||
it('logs when a reminder is removed', () => {
|
||||
const update = vi.fn().mockReturnValue({
|
||||
updatedTrip: { id: 9 }, changes: {}, newTitle: 'b', newReminder: 0, oldReminder: 5,
|
||||
});
|
||||
const s = svc({ update } as Partial<TripsService>);
|
||||
expect(new TripsController(s).update(user, '9', { reminder_days: 0 }, req)).toEqual({ trip: { id: 9 } });
|
||||
});
|
||||
|
||||
it('maps a NotFoundError to 404 and a ValidationError to 400', () => {
|
||||
const nf = svc({ update: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) } as Partial<TripsService>);
|
||||
expect(thrown(() => new TripsController(nf).update(user, '9', { title: 'b' }, req))).toEqual({ status: 404, body: { error: 'gone' } });
|
||||
const ve = svc({ update: vi.fn().mockImplementation(() => { throw new ValidationError('bad'); }) } as Partial<TripsService>);
|
||||
expect(thrown(() => new TripsController(ve).update(user, '9', { title: 'b' }, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
||||
});
|
||||
|
||||
it('re-throws an unknown error from update', () => {
|
||||
const s = svc({ update: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial<TripsService>);
|
||||
expect(() => new TripsController(s).update(user, '9', { title: 'b' }, req)).toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/copy', () => {
|
||||
@@ -104,6 +176,14 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
|
||||
expect(new TripsController(s).remove(user, '9', req, 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, 'sock');
|
||||
});
|
||||
|
||||
it('admin delete logs the owner', () => {
|
||||
const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: true, ownerEmail: 'owner@x.y' });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), remove, broadcast } as Partial<TripsService>);
|
||||
expect(new TripsController(s).remove(user, '9', req)).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('members', () => {
|
||||
@@ -122,6 +202,25 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
|
||||
expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y');
|
||||
});
|
||||
|
||||
it('POST 404 without trip access', () => {
|
||||
const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new TripsController(s).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('POST maps NotFoundError to 404, ValidationError to 400, re-throws others', () => {
|
||||
const nf = svc({ addMember: vi.fn().mockImplementation(() => { throw new NotFoundError('no user'); }) } as Partial<TripsService>);
|
||||
expect(thrown(() => new TripsController(nf).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'no user' } });
|
||||
const ve = svc({ addMember: vi.fn().mockImplementation(() => { throw new ValidationError('already a member'); }) } as Partial<TripsService>);
|
||||
expect(thrown(() => new TripsController(ve).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 400, body: { error: 'already a member' } });
|
||||
const other = svc({ addMember: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial<TripsService>);
|
||||
expect(() => new TripsController(other).addMember(user, '9', 'bob@x.y')).toThrow('boom');
|
||||
});
|
||||
|
||||
it('DELETE 404 without trip access', () => {
|
||||
const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new TripsController(s).removeMember(user, '9', '2'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('DELETE self needs no permission; removing others needs member_manage', () => {
|
||||
const removeMember = vi.fn();
|
||||
const s = svc({ can: vi.fn().mockReturnValue(false), removeMember } as Partial<TripsService>);
|
||||
@@ -151,6 +250,20 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
|
||||
expect(deleteOldCover).toHaveBeenCalledWith('/old.jpg');
|
||||
expect(updateCoverImage).toHaveBeenCalledWith('9', '/uploads/covers/abc.jpg');
|
||||
});
|
||||
|
||||
it('403 in demo mode for a demo account', () => {
|
||||
const prev = process.env.DEMO_MODE;
|
||||
process.env.DEMO_MODE = 'true';
|
||||
isDemoEmail.mockReturnValueOnce(true);
|
||||
try {
|
||||
expect(thrown(() => new TripsController(svc()).cover(user, '9', file))).toEqual({
|
||||
status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
|
||||
});
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/export.ics', () => {
|
||||
@@ -164,6 +277,13 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="trip.ics"');
|
||||
expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR');
|
||||
});
|
||||
|
||||
it('maps a NotFoundError from the export to 404 and re-throws others', () => {
|
||||
const nf = svc({ exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) } as Partial<TripsService>);
|
||||
expect(thrown(() => new TripsController(nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } });
|
||||
const other = svc({ exportICS: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial<TripsService>);
|
||||
expect(() => new TripsController(other).exportIcs(user, '9', makeRes())).toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /:id/copy maps a copy failure to 500', () => {
|
||||
|
||||
@@ -53,6 +53,12 @@ describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () =>
|
||||
s.exportICS('9'); expect(tripSvc.exportICS).toHaveBeenCalledWith('9');
|
||||
});
|
||||
|
||||
it('canAccessTrip delegates to the db helper', () => {
|
||||
canAccessTrip.mockReturnValueOnce({ user_id: 7 });
|
||||
expect(svc().canAccessTrip('9', 7)).toEqual({ user_id: 7 });
|
||||
expect(canAccessTrip).toHaveBeenCalledWith('9', 7);
|
||||
});
|
||||
|
||||
it('can() delegates to checkPermission; broadcast forwards', () => {
|
||||
svc().can('trip_edit', 'user', 1, 1, false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 1, 1, false);
|
||||
@@ -70,6 +76,12 @@ describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () =>
|
||||
expect(result).toMatchObject({ trip: { user_id: 1 }, days: [1], places: [], members: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('bundle tolerates a null member list', () => {
|
||||
tripSvc.listMembers.mockReturnValueOnce({ owner: { id: 1 }, members: null });
|
||||
const result = svc().bundle('9', { user_id: 1 });
|
||||
expect(result).toMatchObject({ members: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('notifyInvite is fire-and-forget (no throw)', () => {
|
||||
expect(() => svc().notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -22,4 +22,31 @@ describe('ZodValidationPipe', () => {
|
||||
expect((thrown as HttpException).getStatus()).toBe(400);
|
||||
expect((thrown as HttpException).getResponse()).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it("labels a root-level (empty path) issue as 'body'", () => {
|
||||
const rootPipe = new ZodValidationPipe(z.string());
|
||||
let thrown: unknown;
|
||||
try {
|
||||
rootPipe.transform(123, meta);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HttpException);
|
||||
const body = (thrown as HttpException).getResponse() as { error: string };
|
||||
expect(body.error).toMatch(/^body: /);
|
||||
});
|
||||
|
||||
it('joins multiple issues with a semicolon', () => {
|
||||
const multiPipe = new ZodValidationPipe(z.object({ a: z.string(), b: z.number() }));
|
||||
let thrown: unknown;
|
||||
try {
|
||||
multiPipe.transform({ a: 1, b: 'x' }, meta);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
const body = (thrown as HttpException).getResponse() as { error: string };
|
||||
expect(body.error).toContain('a: ');
|
||||
expect(body.error).toContain('b: ');
|
||||
expect(body.error).toContain('; ');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,11 @@ export default defineConfig({
|
||||
silent: false,
|
||||
reporters: ['verbose'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
// Vite 8 + Vitest 4 made the sourcemap-based `v8` provider under-report branch
|
||||
// coverage on the SWC/decorator-transformed output (it dropped to ~68% even
|
||||
// though every test passes). `istanbul` instruments the source directly, so
|
||||
// coverage is measured independently of the transform pipeline.
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov', 'text'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
|
||||
Reference in New Issue
Block a user