diff --git a/package-lock.json b/package-lock.json
index c88a942..4f20a3a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -34,12 +34,20 @@
"zod": "^4.3.6"
},
"devDependencies": {
+ "@electric-sql/pglite": "^0.5.4",
+ "@electric-sql/pglite-socket": "^0.2.7",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^7.0.0",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.3",
"@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.6",
+ "jsdom": "^30.0.1",
+ "pglite-prisma-adapter": "^0.7.2",
"postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.1",
@@ -48,6 +56,13 @@
"vitest": "^3.2.4"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -61,6 +76,59 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
+ "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.2.1",
+ "@csstools/css-color-parser": "^4.1.9",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
+ "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -286,6 +354,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -334,6 +412,19 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
"node_modules/@chevrotain/cst-dts-gen": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
@@ -371,34 +462,248 @@
"devOptional": true,
"license": "Apache-2.0"
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
+ "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
+ "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@electric-sql/pglite": {
- "version": "0.3.15",
- "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
- "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
- "devOptional": true,
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.4.tgz",
+ "integrity": "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==",
+ "dev": true,
"license": "Apache-2.0"
},
+ "node_modules/@electric-sql/pglite-age": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-age/-/pglite-age-0.0.5.tgz",
+ "integrity": "sha512-0PoZu8c5tkvqvVrS77Wcz3hK+nSGw8u8omX8cOWzrg7P95lvS3t4ie9tg5RUPSoFS3xkGJFMBwrqWm5BHTDjiQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.4"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pg_hashids": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_hashids/-/pglite-pg_hashids-0.0.5.tgz",
+ "integrity": "sha512-z8iR0ui18LiK8QBCQfq4vrzMIBEUROdYPWJSG5f91v8JHv0lbCc5B/qw5pSKnoht1pXlH0yPcvLF/+GuMYSRdQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.4"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pg_ivm": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_ivm/-/pglite-pg_ivm-0.0.5.tgz",
+ "integrity": "sha512-xx9IjXcRmbHivVLB9DbaA9MsMx8vJGk+cqN2N592oCoKqe+k7SXcUuRh/PSglS7jaHPXM/KvE7KDOh+lip3LfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.4"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pg_textsearch": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_textsearch/-/pglite-pg_textsearch-0.0.6.tgz",
+ "integrity": "sha512-n5lTjivQn/ZvVFdMoW3Rn9u30S0LeAh/sOmjKptnF0nsTa1zIZxa0mAuynzIKrMoUQ0wOFHyB3mj1lIK2UWHUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.4"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pg_uuidv7": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_uuidv7/-/pglite-pg_uuidv7-0.0.5.tgz",
+ "integrity": "sha512-uAatun9hKF6TKYZBc83SaU+snWsg6v2cRatzmty+3e6ai4yDpcOMJBjmA+I73ec0Dk6tykEDsgcsJ0+Eg8af+Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.4"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pgtap": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pgtap/-/pglite-pgtap-0.0.5.tgz",
+ "integrity": "sha512-Bf2L2mcsN8AjqZJVXKoUOIaXljtrapclfgrgx4MN2UMLxJtzoPju0KR9aD89mi09eJEMZuKIDsKrtEdXUPuzLQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.4"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pgvector": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pgvector/-/pglite-pgvector-0.0.5.tgz",
+ "integrity": "sha512-KEPAkD/3FN6WlDM6XPi2Ko1Xk0ecjOU9OfzCoclVkiRoEVNRYwKo1iO+tlkelMgp07SszmSC4JdSBueH2p7uOQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.4"
+ }
+ },
"node_modules/@electric-sql/pglite-socket": {
- "version": "0.0.20",
- "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz",
- "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==",
- "devOptional": true,
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.2.7.tgz",
+ "integrity": "sha512-lhYx1OOVueiWJRa2Uj1QiIjCWmpRnYLNs5rRH/g47EqrYXP/Qt3VvcXGGum4uIouKKMVlPlkXb6E5Uw0aHRymw==",
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"pglite-server": "dist/scripts/server.js"
},
"peerDependencies": {
- "@electric-sql/pglite": "0.3.15"
- }
- },
- "node_modules/@electric-sql/pglite-tools": {
- "version": "0.2.20",
- "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz",
- "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==",
- "devOptional": true,
- "license": "Apache-2.0",
- "peerDependencies": {
- "@electric-sql/pglite": "0.3.15"
+ "@electric-sql/pglite": "0.5.4",
+ "@electric-sql/pglite-age": "0.0.5",
+ "@electric-sql/pglite-pg_hashids": "0.0.5",
+ "@electric-sql/pglite-pg_ivm": "0.0.5",
+ "@electric-sql/pglite-pg_textsearch": "0.0.6",
+ "@electric-sql/pglite-pg_uuidv7": "0.0.5",
+ "@electric-sql/pglite-pgtap": "0.0.5",
+ "@electric-sql/pglite-pgvector": "0.0.5"
}
},
"node_modules/@emnapi/core": {
@@ -1082,6 +1387,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -2405,6 +2728,36 @@
"zeptomatch": "2.1.0"
}
},
+ "node_modules/@prisma/dev/node_modules/@electric-sql/pglite": {
+ "version": "0.3.15",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
+ "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/dev/node_modules/@electric-sql/pglite-socket": {
+ "version": "0.0.20",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz",
+ "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "pglite-server": "dist/scripts/server.js"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.3.15"
+ }
+ },
+ "node_modules/@prisma/dev/node_modules/@electric-sql/pglite-tools": {
+ "version": "0.2.20",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz",
+ "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.3.15"
+ }
+ },
"node_modules/@prisma/driver-adapter-utils": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.2.tgz",
@@ -3894,6 +4247,108 @@
"@swc/counter": "^0.1.3"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
+ "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz",
+ "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -3905,6 +4360,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -4661,6 +5123,16 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -4989,6 +5461,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -5441,6 +5923,27 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -5468,6 +5971,35 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -5626,6 +6158,16 @@
"node": ">=0.10"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -5675,6 +6217,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -5738,6 +6287,19 @@
"node": ">=14"
}
},
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@@ -7132,6 +7694,19 @@
"node": ">=16.9.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
@@ -7208,6 +7783,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -7503,6 +8088,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@@ -7717,6 +8309,57 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+ "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^6.0.5",
+ "@asamuzakjp/dom-selector": "^8.3.0",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.9.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -7936,6 +8579,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -7956,6 +8609,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -7980,6 +8640,16 @@
"node": ">=8.6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -8616,6 +9286,19 @@
"node": ">=6"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -8756,6 +9439,38 @@
"node": ">=4"
}
},
+ "node_modules/pglite-prisma-adapter": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/pglite-prisma-adapter/-/pglite-prisma-adapter-0.7.2.tgz",
+ "integrity": "sha512-NqLXIUguFbgEHpIoyCvAbpEurbK+AUukiv96gIogVnNYWBdhAU0idfTXcJrsS7OdWenYa8QxEQM2ND1cTQrU4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@prisma/driver-adapter-utils": "7.2.0",
+ "postgres-array": "^3.0.4"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": ">=0.2.0",
+ "@prisma/client": ">= 7.1.0"
+ }
+ },
+ "node_modules/pglite-prisma-adapter/node_modules/@prisma/debug": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
+ "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/pglite-prisma-adapter/node_modules/@prisma/driver-adapter-utils": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.2.0.tgz",
+ "integrity": "sha512-gzrUcbI9VmHS24Uf+0+7DNzdIw7keglJsD5m/MHxQOU68OhGVzlphQRobLiDMn8CHNA2XN8uugwKjudVtnfMVQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.2.0"
+ }
+ },
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
@@ -9058,6 +9773,41 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/prisma": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.2.tgz",
@@ -9318,6 +10068,20 @@
"node": ">=8.10.0"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -9379,6 +10143,16 @@
"url": "https://github.com/sponsors/remeda"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -9572,6 +10346,19 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -9996,6 +10783,19 @@
"node": ">=4"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -10101,6 +10901,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
@@ -10277,6 +11084,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "7.4.10",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
+ "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.10"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.10",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
+ "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -10290,6 +11117,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -10477,6 +11330,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
+ "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -10867,6 +11730,54 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
+ "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -10999,6 +11910,23 @@
"node": ">=0.10.0"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/package.json b/package.json
index c368843..07a0228 100644
--- a/package.json
+++ b/package.json
@@ -43,12 +43,20 @@
"zod": "^4.3.6"
},
"devDependencies": {
+ "@electric-sql/pglite": "^0.5.4",
+ "@electric-sql/pglite-socket": "^0.2.7",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^7.0.0",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.3",
"@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.6",
+ "jsdom": "^30.0.1",
+ "pglite-prisma-adapter": "^0.7.2",
"postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.1",
diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md
new file mode 100644
index 0000000..b5a97d5
--- /dev/null
+++ b/tests/TEST_PLAN.md
@@ -0,0 +1,154 @@
+# Test Plan — mohfarawati.de
+
+Comprehensive automated test coverage for the multilingual Next.js portfolio + admin
+workspace. Focus: **code correctness**. Scope excludes Playwright / browser E2E (per request).
+
+## 1. Goals & principles
+
+- Cover every application module: pure helpers, validation/schemas, data layer (Prisma),
+ server actions, API routes, middleware, forms, components, and architecture rules.
+- **Do not change production behaviour.** Tests observe the code as-is. If a test reveals a
+ real bug or an architecture-rule violation, it is flagged for the owner — production code is
+ not changed without approval.
+- Deterministic and self-contained: no external network, no reliance on a running app server.
+- Real Postgres for the data layer (not mocks). See §3.
+
+## 2. Test taxonomy & runner layout
+
+Vitest with three **projects** (isolated environments), selected by file location:
+
+| Project | Env | Location | Parallel | Purpose |
+|---|---|---|---|---|
+| `unit` | node | `tests/unit/**` | yes | Pure functions, schemas, formatting, mapping logic |
+| `integration` | node | `tests/integration/**` | serial | Prisma data layer, server actions, API routes, middleware |
+| `component` | jsdom | `tests/component/**` | yes | React components & forms (RTL) |
+
+Legacy flat `tests/*.test.ts` files are folded into the new structure (kept passing).
+
+### Tooling added (dev-only)
+- `@testing-library/react`, `@testing-library/dom`, `@testing-library/jest-dom`,
+ `@testing-library/user-event`, `jsdom`, `@vitejs/plugin-react` — component tests.
+- `@electric-sql/pglite`, `pglite-prisma-adapter` — an **embedded real Postgres**
+ (Postgres compiled to WASM) that runs the project's actual migrations and Prisma queries
+ in-process, via a Prisma driver adapter.
+
+### Database strategy (real Postgres)
+`tests/helpers/integration-setup.ts` + `tests/helpers/global-db-setup.ts`:
+- If `TEST_DATABASE_URL` is set → the real `lib/prisma` singleton is used unchanged, pointed
+ at that Postgres (e.g. the Docker instance). The global setup resets the schema and applies
+ every `prisma/migrations/*/migration.sql` once before the run.
+- Otherwise → `lib/prisma` is mocked (test-only) with a Prisma client backed by an in-process
+ PGlite database. Each worker gets its own isolated database with the migrations applied —
+ real Postgres semantics, no external server, no shared-state races. **Production code is
+ never modified.**
+- `resetDb()` (TRUNCATE all tables, restart identities) runs in `beforeEach`.
+- The integration project runs **serially** (`fileParallelism: false`); each file gets a fresh
+ database connection.
+
+### Next.js runtime mocks (integration)
+Server actions/middleware depend on the Next runtime. `tests/helpers/next-mocks.ts` provides:
+- `next/navigation` → `redirect()` throws a catchable `NEXT_REDIRECT` carrying the URL.
+- `next/dist/client/components/redirect-error` → `isRedirectError()` recognises the above.
+- `next/cache` → `revalidatePath()` spy (no-op, asserted).
+- `next/headers` → controllable `cookies()` / `headers()` stores.
+- `@/lib/admin-auth` `isAdminAuthenticated` → toggled per test (auth guard tests).
+- `nodemailer` → captured transport (no real SMTP).
+
+## 3. Coverage matrix
+
+### 3.1 Unit — pure lib
+
+| Module | Cases |
+|---|---|
+| `admin-routing` | host resolution (forwarded/comma/port), `isAdminHost`, `hasDedicatedAdminHost`, legacy/dev/internal path predicates, `toInternalAdminPath`, `fromDevelopmentAdminPath`, `getAdminAppPath` dev vs prod, `buildAdminUrl`/`buildSiteUrl`, env overrides, normalization edge cases |
+| `admin-feedback` | `withFlash` (success/error/both/none, encoding), `readFlash` |
+| `admin-navigation` | tree shape, `active`/`expanded` flags for each section, portfolio child mapping, href de-dup filter |
+| `form-data` | `isCheckedFormValue` truthy/falsey set |
+| `locale` | `isSupportedLocale`, `resolveLocale`, `getDirection` (rtl for ar), `stripLocalePrefix`, `getLocalizedPath(WithDefault)` incl. prefix stripping/rebuilding |
+| `utils` | `cn` merge/dedupe/conditional |
+| `site-theme` | `buildSiteThemeTokens` (hex→hsl, derive dark/secondary, clamps), `buildSiteThemeStyleText` structure, invalid hex fallback |
+| `marquee-settings` | defaults, `parseMarqueeSettingsValue` (invalid json, partial, trims), `syncMarqueeSettingsToGermanSource`, `splitMarqueeRowItems` (newline/comma/blank) |
+| `site-settings` | `normalizeSiteDefaultLocale`, `normalizeSitePrimaryColor`, `buildDefaultSiteSettings`, `parseSiteSettingsValue` (merge, invalid json, legacy title, invalid locale/color) |
+| `site-icons` | `buildSiteIconUrls` (version, relative vs absolute favicon url, name fallback), `buildSiteIconResponse` transparent fallback for non-managed paths |
+| `media-storage` (pure) | `sanitizeBaseName`, `getExtensionForMimeType` (all mimes + unknown), `isManagedMediaFilePath`, `resolveMediaUploadPath` (root confinement + traversal guard) |
+| `media-validation` | `mediaFieldInputSchema`: library needs assetId, external needs url, url format rule, upload mode, kind enum, trimming |
+| `media-service` (pure) | `inferMediaKindFromMimeType`, `inferMediaKindFromFileName`, `getKindFromUploadFile` |
+| `portfolio` (pure) | `resolvePortfolioProjectViewMode`, `getLocalizedValue` (direct/fallback/any) |
+| `portfolio-form-progress` | slug/year/sortOrder validators, section readiness per type, asset readiness, wizard progress, first incomplete step |
+| `portfolio-validation` | category/section/asset/project schemas: required fields, slug regex, coercions, view modes, section superRefine per type, media refinements, url rules |
+| `metadata` | `applyTitleTemplateFn`, `buildLocaleAlternates`, `buildAppMetadataFromConfig`, `buildLocalizedMetadataFromConfig` (title template skip, description fallback, og/twitter, icons) |
+| `mail` | `createSmtpTransport` (required host/user/pass errors, port/secure), `sendMail` (from with/without name), `sendContactMessage` (recipient + fallback, body fields), `sendTestEmail` (recipient fallback, transport reject) |
+
+### 3.2 Integration — data layer (real DB)
+
+| Module | Cases |
+|---|---|
+| `app-config` | maintenance get/set, site settings get (fallback name from `siteName` key) / update roundtrip, mail settings get/update, marquee get/update (german sync), `getSiteSettingsMediaBindings` (mediaUsage → bindings per field) |
+| `media` | create asset, get by id (+usages), list, `getMediaOptions` kind filter, `replaceEntityMediaUsages` (transactional replace, unique constraint), `deleteEntityMediaUsages`, `getPortfolioMediaBindings` routing by usageType, `countMediaUsageReferences` |
+| `portfolio` (queries) | admin categories (+project counts), active categories/by-slug, admin projects (status/category filters + ordering), published projects/by-slug, by-id with media bindings, localized mapping, `onDelete` Restrict/Cascade behaviour |
+| `media-service.resolveMediaSelection` | library (found/missing), external (creates asset, filename from url), missing+required error, not-required empty |
+| `admin-auth` (lockout) | `registerFailedAdminAttempt` increments & locks at threshold, `getAdminLockState`, `resetAdminFailedAttempts`, IP hashing via mocked headers; token `isPasswordValid`/verify with env |
+
+### 3.3 Integration — API routes & middleware
+
+| Target | Cases |
+|---|---|
+| `GET /api/health` | 200 + `database: up`; 503 + `database: down` when query throws |
+| `GET /api/site/default-locale` | returns runtime `defaultLocale` + `maintenanceEnabled`, no-store header |
+| `proxy` (middleware) | runtime default-locale passthrough, safe fallback on fetch failure, maintenance redirect, `SITE_RUNTIME_ORIGIN`, admin host rewrite → internal, dev `/root` handling, legacy 404 in prod, internal path 404 for non-admin in prod, basic-auth challenge/valid |
+
+### 3.4 Integration — server actions
+
+| Action file | Cases |
+|---|---|
+| `contact/actions` | valid → sends mail + redirect `/success`; invalid (short name/bad email/short message) → redirect with error; locale resolution |
+| `maintenance/actions` | unauth → redirect to admin root; enable/disable toggles config + revalidates + success flash |
+| `marquee/actions` | unauth guard; empty german rows throw per-row error → error flash; valid → saves (german-synced) + success flash |
+| `smtp/actions` | `parseMailSettingsFormData` (port parse error, password retention when blank), save → success; `sendTestEmailAction` success + failure |
+| `site-settings/actions` | brand save (primary color normalize, media selection + usage wiring, cleanup on error), localization save (siteName required, title template must contain `{pageTitle}`), `parseJsonObject` guard |
+| `media/actions` | create (kind image/document, missing file error), delete (not found, in-use guard, managed-file removal) |
+| `portfolio/actions` | `upsertCategoryAction` create/update + P2002 unique message; `deleteCategoryAction` blocks when projects exist; `saveProjectAction` create + update, sections/assets replace, `publishedAt` first-publish logic, media usage wiring, validation + error cleanup of created media; `deleteProjectAction` not-found + cascade + usage cleanup |
+
+### 3.5 Component (jsdom + RTL)
+
+Global mocks: `framer-motion`, `gsap`, `next/link`, `next/image`, `next-intl`, `next/navigation`.
+
+| Component | Cases |
+|---|---|
+| `ui/badge` | variant classes, custom className merge, passthrough props |
+| `ui/input` | renders, forwardRef, type/placeholder/disabled, className merge |
+| `ui/textarea`, `ui/label`, `ui/card`*, `ui/app-card`, `ui/separator`, `ui/table`* | render, props, ref, composition |
+| `admin/admin-flash` | null when empty, success `role=status`, error `role=alert`, both, className |
+| `admin/marquee-settings-form` | renders 4 rows, default values, submit wiring to action |
+| `site/portfolio-category-filter` | "all" link + per-category links, active state, localized labels, hrefs |
+| `dashboard/dashboard-card`, `dashboard/stats-card` | presentational render, props |
+| `layout/container`, `layout/hero-badge`, `home/section-heading`, `home/bento-card` | presentational render, children, className |
+
+(*multi-part primitives tested for sub-component composition.)
+
+### 3.6 Architecture-rule tests (`tests/integration/architecture`)
+
+Enforced from `CLAUDE.md`:
+1. **No Prisma in client components** — no file containing `"use client"` imports `lib/prisma`.
+2. **Business logic out of UI** — components don't import server-only data modules directly
+ (allow-list of pure `lib/*` view/format helpers).
+3. **Server actions are guarded** — every exported action in `app/**/actions.ts` calls an auth
+ guard (`ensureAdmin`/`requireAdminAuth`) except the public contact action.
+4. **`"use server"` directive** — every `actions.ts` begins with `"use server"`.
+5. **`lib/*` does not import from `app/`** — dependency direction.
+6. **Admin mirror parity** — every `page.tsx` under `app/_admin/**` has matching re-export
+ stubs under `app/root/**` and `app/admin-internal/**` pointing back to `_admin`.
+7. **No browser storage in components** — no `localStorage`/`sessionStorage` usage.
+
+## 4. Deliverables & running
+
+```bash
+npm test # all projects
+npx vitest run --project unit
+npx vitest run --project integration
+npx vitest run --project component
+TEST_DATABASE_URL=postgres://… # optional: run integration against real Postgres
+```
+
+Findings (real bugs / rule violations) are reported to the owner; production code is only
+changed after approval.
diff --git a/tests/component/_smoke.test.tsx b/tests/component/_smoke.test.tsx
new file mode 100644
index 0000000..214b581
--- /dev/null
+++ b/tests/component/_smoke.test.tsx
@@ -0,0 +1,11 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Badge } from "@/components/ui/badge";
+
+describe("component harness smoke test", () => {
+ it("renders a component into jsdom", () => {
+ render(Hello);
+ expect(screen.getByText("Hello")).toBeInTheDocument();
+ });
+});
diff --git a/tests/component/admin-flash.test.tsx b/tests/component/admin-flash.test.tsx
new file mode 100644
index 0000000..e76d230
--- /dev/null
+++ b/tests/component/admin-flash.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AdminFlash } from "@/components/admin/admin-flash";
+
+describe("AdminFlash", () => {
+ it("renders nothing when there are no messages", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders a success message with a status role", () => {
+ render();
+ const status = screen.getByRole("status");
+ expect(status).toHaveTextContent("Saved.");
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("renders an error message with an alert role", () => {
+ render();
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("Failed.");
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("renders both a success and error message together", () => {
+ render();
+ expect(screen.getByRole("status")).toHaveTextContent("Yes");
+ expect(screen.getByRole("alert")).toHaveTextContent("No");
+ });
+
+ it("applies a custom className to the wrapper", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass("mb-4");
+ });
+});
diff --git a/tests/component/dashboard-cards.test.tsx b/tests/component/dashboard-cards.test.tsx
new file mode 100644
index 0000000..c1b5374
--- /dev/null
+++ b/tests/component/dashboard-cards.test.tsx
@@ -0,0 +1,37 @@
+import { Activity } from "lucide-react";
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { DashboardCard } from "@/components/dashboard/dashboard-card";
+import { StatsCard } from "@/components/dashboard/stats-card";
+
+describe("StatsCard", () => {
+ it("renders the title and value", () => {
+ render();
+ expect(screen.getByText("Visitors")).toBeInTheDocument();
+ expect(screen.getByText("1,234")).toBeInTheDocument();
+ });
+
+ it("renders an optional description and footer", () => {
+ render(
+ footer} />,
+ );
+ expect(screen.getByText("Up 5%")).toBeInTheDocument();
+ expect(screen.getByText("footer")).toBeInTheDocument();
+ });
+
+ it("omits the icon container when no icon is provided", () => {
+ const { container } = render();
+ expect(container.querySelector("svg")).toBeNull();
+ });
+});
+
+describe("DashboardCard", () => {
+ it("passes its props through to a StatsCard", () => {
+ render();
+ expect(screen.getByText("Sales")).toBeInTheDocument();
+ expect(screen.getByText("42")).toBeInTheDocument();
+ expect(screen.getByText("today")).toBeInTheDocument();
+ });
+});
diff --git a/tests/component/marquee-settings-form.test.tsx b/tests/component/marquee-settings-form.test.tsx
new file mode 100644
index 0000000..73bda5e
--- /dev/null
+++ b/tests/component/marquee-settings-form.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { MarqueeSettingsForm } from "@/components/admin/marquee-settings-form";
+import { buildDefaultMarqueeSettings } from "@/lib/marquee-settings";
+
+describe("MarqueeSettingsForm", () => {
+ it("renders a textarea per row with german default values", () => {
+ const settings = buildDefaultMarqueeSettings();
+ settings.locales.de.row1 = "First row value";
+ render();
+
+ for (const name of ["row1-de", "row2-de", "row3-de", "row4-de"]) {
+ const field = document.querySelector(`textarea[name="${name}"]`);
+ expect(field).not.toBeNull();
+ }
+ expect((document.querySelector('textarea[name="row1-de"]') as HTMLTextAreaElement).value).toBe(
+ "First row value",
+ );
+ });
+
+ it("wires the server action onto the form and renders a submit button", () => {
+ const action = vi.fn();
+ render();
+ expect(document.querySelector("form#marquee-settings-form")).not.toBeNull();
+ expect(screen.getByRole("button", { name: /save marquee/i })).toBeInTheDocument();
+ });
+});
diff --git a/tests/component/portfolio-category-filter.test.tsx b/tests/component/portfolio-category-filter.test.tsx
new file mode 100644
index 0000000..615772d
--- /dev/null
+++ b/tests/component/portfolio-category-filter.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
+import type { PortfolioCategoryView } from "@/lib/portfolio";
+
+function category(overrides: Partial = {}): PortfolioCategoryView {
+ return {
+ id: overrides.id ?? "c1",
+ slug: overrides.slug ?? "branding",
+ name: overrides.name ?? { ar: "الهوية", en: "Branding", de: "Branding" },
+ description: overrides.description ?? { ar: "", en: "", de: "" },
+ sortOrder: overrides.sortOrder ?? 0,
+ isActive: overrides.isActive ?? true,
+ };
+}
+
+describe("PortfolioCategoryFilter", () => {
+ const categories = [
+ category({ id: "c1", slug: "branding", name: { ar: "الهوية", en: "Branding", de: "Marke" } }),
+ category({ id: "c2", slug: "web", name: { ar: "ويب", en: "Web", de: "Web" } }),
+ ];
+
+ it("renders the all-projects link and one link per category", () => {
+ render(
+ ,
+ );
+ const links = screen.getAllByRole("link");
+ expect(links).toHaveLength(3);
+ expect(screen.getByRole("link", { name: "All" })).toHaveAttribute("href", "/en/portfolio");
+ expect(screen.getByRole("link", { name: "Branding" })).toHaveAttribute(
+ "href",
+ "/en/portfolio/category/branding",
+ );
+ });
+
+ it("uses localized labels for the active locale", () => {
+ render(
+ ,
+ );
+ // German locale on the default locale -> unprefixed paths and German labels
+ expect(screen.getByRole("link", { name: "Marke" })).toHaveAttribute(
+ "href",
+ "/portfolio/category/branding",
+ );
+ expect(screen.getByRole("link", { name: "Alle" })).toHaveAttribute("href", "/portfolio");
+ });
+
+ it("highlights the active category", () => {
+ render(
+ ,
+ );
+ const active = screen.getByRole("link", { name: "Web" });
+ expect(active).toHaveClass("bg-primary");
+ });
+});
diff --git a/tests/component/ui-primitives.test.tsx b/tests/component/ui-primitives.test.tsx
new file mode 100644
index 0000000..ac9c865
--- /dev/null
+++ b/tests/component/ui-primitives.test.tsx
@@ -0,0 +1,149 @@
+import { createRef } from "react";
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AppCard } from "@/components/ui/app-card";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { Container } from "@/components/layout/container";
+import { HeroBadge } from "@/components/layout/hero-badge";
+import { SectionHeading } from "@/components/home/section-heading";
+
+describe("Badge", () => {
+ it("renders children and default variant classes", () => {
+ render(New);
+ const badge = screen.getByText("New");
+ expect(badge).toHaveClass("bg-primary");
+ });
+
+ it("applies a chosen variant and merges custom classNames", () => {
+ render(OK);
+ const badge = screen.getByText("OK");
+ expect(badge).toHaveClass("bg-status-success-soft");
+ expect(badge).toHaveClass("custom-class");
+ });
+
+ it("passes through arbitrary props", () => {
+ render(X);
+ expect(screen.getByTestId("b")).toHaveAttribute("title", "tip");
+ });
+});
+
+describe("Input", () => {
+ it("forwards the ref and renders attributes", () => {
+ const ref = createRef();
+ render();
+ const input = screen.getByPlaceholderText("you@example.com");
+ expect(ref.current).toBe(input);
+ expect(input).toHaveAttribute("type", "email");
+ expect(input).toBeDisabled();
+ });
+
+ it("merges custom classes", () => {
+ render();
+ expect(screen.getByLabelText("field")).toHaveClass("w-20");
+ });
+});
+
+describe("Textarea", () => {
+ it("forwards the ref and renders a default value", () => {
+ const ref = createRef();
+ render();
+ const textarea = screen.getByLabelText("msg");
+ expect(ref.current).toBe(textarea);
+ expect(textarea).toHaveValue("hello");
+ });
+});
+
+describe("Label", () => {
+ it("associates with a control via htmlFor", () => {
+ render();
+ expect(screen.getByText("Name")).toHaveAttribute("for", "name");
+ });
+});
+
+describe("Separator", () => {
+ it("defaults to a horizontal separator", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass("h-px", "w-full");
+ });
+
+ it("supports a vertical orientation", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass("h-full", "w-px");
+ });
+});
+
+describe("Card family", () => {
+ it("composes header, title, description and content", () => {
+ render(
+
+
+ Title
+ Desc
+
+ Body
+ ,
+ );
+ expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument();
+ expect(screen.getByText("Desc")).toBeInTheDocument();
+ expect(screen.getByText("Body")).toBeInTheDocument();
+ });
+});
+
+describe("AppCard", () => {
+ it("renders a single-layer card with its children", () => {
+ render(Single);
+ expect(screen.getByText("Single")).toBeInTheDocument();
+ });
+
+ it("renders a double-layer card (default) with its children", () => {
+ render(Double);
+ expect(screen.getByText("Double")).toBeInTheDocument();
+ });
+});
+
+describe("Container", () => {
+ it("applies the size variant classes", () => {
+ const { container } = render(Body);
+ expect(container.firstChild).toHaveClass("max-w-narrow");
+ });
+
+ it("renders as a child element when asChild is set", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("as-child").tagName).toBe("SECTION");
+ });
+});
+
+describe("HeroBadge", () => {
+ it("renders children and optional trailing content", () => {
+ render(→}>Available);
+ expect(screen.getByText("Available")).toBeInTheDocument();
+ expect(screen.getByText("→")).toBeInTheDocument();
+ });
+});
+
+describe("SectionHeading", () => {
+ it("renders eyebrow, title and description", () => {
+ render();
+ expect(screen.getByText("Work")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Projects" })).toBeInTheDocument();
+ expect(screen.getByText("What I build")).toBeInTheDocument();
+ });
+
+ it("centers content when align is center", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toHaveClass("text-center");
+ });
+});
diff --git a/tests/helpers/component-setup.ts b/tests/helpers/component-setup.ts
new file mode 100644
index 0000000..0c1ee6a
--- /dev/null
+++ b/tests/helpers/component-setup.ts
@@ -0,0 +1,133 @@
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+import "@testing-library/jest-dom/vitest";
+import React from "react";
+
+afterEach(() => {
+ cleanup();
+});
+
+// ---------------------------------------------------------------------------
+// Global mocks for heavy / browser-only libraries so components render in jsdom.
+// (vi.mock in a setup file applies to every test file in the component project.)
+// ---------------------------------------------------------------------------
+
+vi.mock("framer-motion", () => {
+ const passthrough = (tag: string) =>
+ React.forwardRef(function MotionMock(
+ { children, ...props }: Record & { children?: React.ReactNode },
+ ref: React.Ref,
+ ) {
+ const domProps: Record = {};
+ for (const [key, value] of Object.entries(props)) {
+ // Drop motion-only props that would warn as unknown DOM attributes.
+ if (
+ /^(initial|animate|exit|transition|variants|whileHover|whileTap|whileInView|whileFocus|whileDrag|drag|layout|layoutId|viewport|custom|onAnimationComplete|style)$/.test(
+ key,
+ )
+ ) {
+ if (key === "style") domProps.style = value;
+ continue;
+ }
+ domProps[key] = value;
+ }
+ return React.createElement(tag, { ...domProps, ref }, children as React.ReactNode);
+ });
+
+ const motion = new Proxy(
+ {},
+ {
+ get: (_target, tag: string) => passthrough(tag),
+ },
+ );
+
+ return {
+ motion,
+ AnimatePresence: ({ children }: { children?: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+ useReducedMotion: () => true,
+ useInView: () => true,
+ useScroll: () => ({ scrollYProgress: { on: () => () => {}, get: () => 0 } }),
+ useTransform: () => 0,
+ useMotionValue: (value: unknown) => ({ get: () => value, set: () => {}, on: () => () => {} }),
+ useAnimate: () => [React.useRef(null), () => Promise.resolve()],
+ };
+});
+
+vi.mock("gsap", () => {
+ const tween = { kill: () => {}, play: () => {}, pause: () => {}, progress: () => {} };
+ const gsap = {
+ to: () => tween,
+ from: () => tween,
+ fromTo: () => tween,
+ set: () => tween,
+ timeline: () => ({
+ to: () => ({}),
+ from: () => ({}),
+ fromTo: () => ({}),
+ add: () => ({}),
+ kill: () => {},
+ }),
+ registerPlugin: () => {},
+ context: (fn: () => void) => {
+ if (typeof fn === "function") fn();
+ return { revert: () => {}, kill: () => {} };
+ },
+ matchMedia: () => ({ add: () => {}, revert: () => {} }),
+ utils: { toArray: (v: unknown) => (Array.isArray(v) ? v : [v]) },
+ };
+ return { gsap, default: gsap };
+});
+
+vi.mock("@gsap/react", () => ({
+ useGSAP: () => ({ context: {}, contextSafe: (fn: unknown) => fn }),
+}));
+
+vi.mock("next/link", () => ({
+ default: React.forwardRef(function LinkMock(
+ { href, children, ...props }: Record & { href?: unknown; children?: React.ReactNode },
+ ref: React.Ref,
+ ) {
+ return React.createElement("a", { href: String(href ?? ""), ref, ...props }, children as React.ReactNode);
+ }),
+}));
+
+vi.mock("next/image", () => ({
+ default: ({ src, alt, ...props }: Record & { src?: unknown; alt?: string }) =>
+ React.createElement("img", { src: String(src ?? ""), alt: alt ?? "", ...props }),
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/",
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ refresh: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ prefetch: vi.fn(),
+ }),
+ useSearchParams: () => new URLSearchParams(),
+ useParams: () => ({}),
+ redirect: vi.fn(),
+ notFound: vi.fn(),
+}));
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => {
+ const t = (key: string) => key;
+ t.rich = (key: string) => key;
+ t.markup = (key: string) => key;
+ t.raw = (key: string) => key;
+ return t;
+ },
+ useLocale: () => "de",
+ useFormatter: () => ({
+ dateTime: (v: Date) => v.toISOString(),
+ number: (v: number) => String(v),
+ relativeTime: (v: unknown) => String(v),
+ }),
+ useMessages: () => ({}),
+ NextIntlClientProvider: ({ children }: { children?: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+}));
diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts
new file mode 100644
index 0000000..2f9b737
--- /dev/null
+++ b/tests/helpers/factories.ts
@@ -0,0 +1,117 @@
+import { MediaKind, MediaSource, MediaUsageType, PortfolioSectionType } from "@prisma/client";
+
+import { prisma } from "@/lib/prisma";
+
+let counter = 0;
+function uniq(prefix: string) {
+ counter += 1;
+ return `${prefix}-${counter}`;
+}
+
+export function createCategory(overrides: Record = {}) {
+ const slug = (overrides.slug as string) ?? uniq("cat");
+ return prisma.category.create({
+ data: {
+ slug,
+ nameAr: "الاسم",
+ nameEn: "Name",
+ nameDe: "Name",
+ descriptionAr: "وصف",
+ descriptionEn: "Description",
+ descriptionDe: "Beschreibung",
+ sortOrder: 0,
+ isActive: true,
+ ...overrides,
+ },
+ });
+}
+
+export async function createProject(overrides: Record = {}) {
+ const categoryId = (overrides.categoryId as string) ?? (await createCategory()).id;
+ const isPublished = (overrides.isPublished as boolean) ?? true;
+ const slug = (overrides.slug as string) ?? uniq("proj");
+ return prisma.portfolioProject.create({
+ data: {
+ categoryId,
+ slug,
+ viewMode: "GRID",
+ titleAr: "عنوان",
+ titleEn: "Title",
+ titleDe: "Titel",
+ summaryAr: "ملخص",
+ summaryEn: "Summary",
+ summaryDe: "Zusammenfassung",
+ clientName: "Client",
+ projectYear: 2025,
+ serviceLabelAr: "خدمة",
+ serviceLabelEn: "Service",
+ serviceLabelDe: "Service",
+ isFeatured: false,
+ sortOrder: 0,
+ ...overrides,
+ isPublished,
+ publishedAt: isPublished ? new Date() : null,
+ },
+ });
+}
+
+export function createSection(projectId: string, overrides: Record = {}) {
+ return prisma.portfolioSection.create({
+ data: {
+ projectId,
+ type: PortfolioSectionType.RICH_TEXT,
+ titleAr: "ع",
+ titleEn: "t",
+ titleDe: "t",
+ bodyAr: "ب",
+ bodyEn: "b",
+ bodyDe: "b",
+ sortOrder: 0,
+ ...overrides,
+ },
+ });
+}
+
+export function createAsset(projectId: string, overrides: Record = {}) {
+ return prisma.portfolioAsset.create({
+ data: {
+ projectId,
+ kind: "IMAGE",
+ filePath: "/uploads/media/assets/x.svg",
+ altAr: "ع",
+ altEn: "a",
+ altDe: "a",
+ sortOrder: 0,
+ ...overrides,
+ },
+ });
+}
+
+export function createMediaAsset(overrides: Record = {}) {
+ return prisma.mediaAsset.create({
+ data: {
+ source: MediaSource.EXTERNAL,
+ kind: MediaKind.IMAGE,
+ url: (overrides.url as string) ?? `https://cdn.example.com/${uniq("img")}.png`,
+ fileName: "img.png",
+ label: "Image",
+ ...overrides,
+ },
+ });
+}
+
+export function createMediaUsage(
+ assetId: string,
+ overrides: Record = {},
+) {
+ return prisma.mediaUsage.create({
+ data: {
+ assetId,
+ usageType: MediaUsageType.GENERIC,
+ entityType: "test-entity",
+ entityId: "e1",
+ fieldKey: uniq("field"),
+ ...overrides,
+ },
+ });
+}
diff --git a/tests/helpers/fs-capability.ts b/tests/helpers/fs-capability.ts
new file mode 100644
index 0000000..d5e588e
--- /dev/null
+++ b/tests/helpers/fs-capability.ts
@@ -0,0 +1,23 @@
+import { mkdirSync, unlinkSync, writeFileSync } from "fs";
+import path from "path";
+
+import { MEDIA_UPLOAD_ROOT } from "@/lib/media-storage";
+
+/**
+ * Some sandboxed / read-only-mount environments allow writes but forbid `unlink`.
+ * Filesystem round-trip tests that create AND delete managed media files self-skip
+ * when deletion isn't permitted, so the suite stays green there while still running
+ * fully on a normal filesystem (developer machine / CI).
+ */
+export const canManageUploads: boolean = (() => {
+ try {
+ const dir = path.join(MEDIA_UPLOAD_ROOT, "tests");
+ mkdirSync(dir, { recursive: true });
+ const probe = path.join(dir, `.cap-probe-${process.pid}-${Date.now()}`);
+ writeFileSync(probe, "probe");
+ unlinkSync(probe);
+ return true;
+ } catch {
+ return false;
+ }
+})();
diff --git a/tests/helpers/global-db-setup.ts b/tests/helpers/global-db-setup.ts
new file mode 100644
index 0000000..8e29cf3
--- /dev/null
+++ b/tests/helpers/global-db-setup.ts
@@ -0,0 +1,35 @@
+import { readFileSync, readdirSync } from "fs";
+import path from "path";
+
+/**
+ * Global integration setup.
+ *
+ * Only needed when running against a real Postgres via TEST_DATABASE_URL: reset the
+ * schema and apply every migration once before the workers start. When
+ * TEST_DATABASE_URL is not set, each worker spins up its own in-process PGlite database
+ * (see tests/helpers/integration-setup.ts) and this is a no-op.
+ */
+
+const MIGRATIONS_DIR = path.resolve(process.cwd(), "prisma", "migrations");
+
+export default async function setup() {
+ const connectionString = process.env.TEST_DATABASE_URL?.trim();
+ if (!connectionString) {
+ return;
+ }
+
+ const pg = (await import("pg")).default;
+ const client = new pg.Client({ connectionString });
+ await client.connect();
+ try {
+ await client.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;");
+ const dirs = readdirSync(MIGRATIONS_DIR)
+ .filter((entry) => /^\d/.test(entry))
+ .sort();
+ for (const dir of dirs) {
+ await client.query(readFileSync(path.join(MIGRATIONS_DIR, dir, "migration.sql"), "utf8"));
+ }
+ } finally {
+ await client.end();
+ }
+}
diff --git a/tests/helpers/integration-setup.ts b/tests/helpers/integration-setup.ts
new file mode 100644
index 0000000..a2cdd48
--- /dev/null
+++ b/tests/helpers/integration-setup.ts
@@ -0,0 +1,69 @@
+import { readFileSync, readdirSync } from "fs";
+import path from "path";
+
+import { afterAll, beforeEach, vi } from "vitest";
+
+/**
+ * Integration database wiring.
+ *
+ * - If TEST_DATABASE_URL is set, the real `lib/prisma` singleton is used unchanged,
+ * pointed at that Postgres (e.g. the Docker instance). Migrations are applied once
+ * by the global setup; files run serially and truncate between tests.
+ *
+ * - Otherwise, `lib/prisma` is mocked with a Prisma client backed by an in-process
+ * PGlite database (Postgres compiled to WASM) — real Postgres semantics, fully
+ * isolated per worker, no external server. Production code is never modified.
+ */
+
+const realDbUrl = process.env.TEST_DATABASE_URL?.trim();
+if (realDbUrl) {
+ process.env.DATABASE_URL = realDbUrl;
+}
+
+vi.mock("@/lib/prisma", async () => {
+ if (process.env.TEST_DATABASE_URL?.trim()) {
+ return await vi.importActual("@/lib/prisma");
+ }
+
+ const { PGlite } = await import("@electric-sql/pglite");
+ const { PrismaPGlite } = await import("pglite-prisma-adapter");
+ const { PrismaClient } = await import("@prisma/client");
+
+ const db = await PGlite.create();
+ const migrationsDir = path.resolve(process.cwd(), "prisma", "migrations");
+ const dirs = readdirSync(migrationsDir)
+ .filter((entry) => /^\d/.test(entry))
+ .sort();
+ for (const dir of dirs) {
+ await db.exec(readFileSync(path.join(migrationsDir, dir, "migration.sql"), "utf8"));
+ }
+
+ const prisma = new PrismaClient({ adapter: new PrismaPGlite(db) });
+ return { prisma };
+});
+
+const { prisma } = await import("@/lib/prisma");
+
+// Truncated in dependency order (children first) between every test for isolation.
+const TABLES = [
+ "MediaUsage",
+ "MediaAsset",
+ "PortfolioAsset",
+ "PortfolioSection",
+ "PortfolioProject",
+ "Category",
+ "AppConfig",
+];
+
+export async function resetDb() {
+ const list = TABLES.map((table) => `"${table}"`).join(", ");
+ await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`);
+}
+
+beforeEach(async () => {
+ await resetDb();
+});
+
+afterAll(async () => {
+ await prisma.$disconnect();
+});
diff --git a/tests/helpers/next-mocks.ts b/tests/helpers/next-mocks.ts
new file mode 100644
index 0000000..b4a1037
--- /dev/null
+++ b/tests/helpers/next-mocks.ts
@@ -0,0 +1,67 @@
+import { vi } from "vitest";
+
+/**
+ * Shared mocks for the Next.js runtime pieces that server actions depend on.
+ * Action tests wire these in with `vi.mock(...)` at the top of each file, e.g.:
+ *
+ * vi.mock("next/navigation", async () => ({
+ * redirect: (await import("@/tests/helpers/next-mocks")).redirect,
+ * }));
+ */
+
+export class RedirectError extends Error {
+ readonly digest = "NEXT_REDIRECT";
+ readonly __isRedirect = true;
+ constructor(public url: string) {
+ super(`NEXT_REDIRECT:${url}`);
+ }
+}
+
+export function redirect(url: string): never {
+ throw new RedirectError(url);
+}
+
+export function isRedirectError(error: unknown): error is RedirectError {
+ return (
+ error instanceof RedirectError ||
+ (typeof error === "object" && error !== null && (error as { __isRedirect?: boolean }).__isRedirect === true)
+ );
+}
+
+export const revalidatePath = vi.fn();
+
+export const adminAuth = { authenticated: true };
+export const clearAdminSessionCookie = vi.fn(async () => {});
+export async function isAdminAuthenticated(): Promise {
+ return adminAuth.authenticated;
+}
+
+/** Run an action and return the URL it redirected to (or throw if it didn't). */
+export async function captureRedirect(run: () => Promise): Promise {
+ try {
+ await run();
+ } catch (error) {
+ if (isRedirectError(error)) {
+ return error.url;
+ }
+ throw error;
+ }
+ throw new Error("Expected the action to redirect, but it returned normally.");
+}
+
+export function resetNextMocks() {
+ revalidatePath.mockClear();
+ clearAdminSessionCookie.mockClear();
+ adminAuth.authenticated = true;
+}
+
+/** Build a FormData from a flat record (strings and Files). */
+export function formDataFrom(fields: Record): FormData {
+ const formData = new FormData();
+ for (const [key, value] of Object.entries(fields)) {
+ if (value !== undefined) {
+ formData.set(key, value);
+ }
+ }
+ return formData;
+}
diff --git a/tests/integration/_smoke.test.ts b/tests/integration/_smoke.test.ts
new file mode 100644
index 0000000..ce67e53
--- /dev/null
+++ b/tests/integration/_smoke.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+
+import { prisma } from "@/lib/prisma";
+
+describe("integration harness smoke test", () => {
+ it("connects to the migrated test database and performs CRUD", async () => {
+ const created = await prisma.category.create({
+ data: {
+ slug: "smoke",
+ nameAr: "a",
+ nameEn: "b",
+ nameDe: "c",
+ descriptionAr: "a",
+ descriptionEn: "b",
+ descriptionDe: "c",
+ },
+ });
+
+ expect(created.id).toBeTruthy();
+ expect(created.isActive).toBe(true);
+
+ const found = await prisma.category.findUnique({ where: { slug: "smoke" } });
+ expect(found?.nameEn).toBe("b");
+ });
+
+ it("resets the database between tests", async () => {
+ const count = await prisma.category.count();
+ expect(count).toBe(0);
+ });
+
+ it("supports enums and appconfig upsert", async () => {
+ await prisma.appConfig.upsert({
+ where: { key: "k" },
+ update: { value: "v2" },
+ create: { key: "k", value: "v1" },
+ });
+ const row = await prisma.appConfig.findUnique({ where: { key: "k" } });
+ expect(row?.value).toBe("v1");
+ });
+});
diff --git a/tests/integration/actions-contact.test.ts b/tests/integration/actions-contact.test.ts
new file mode 100644
index 0000000..da1664d
--- /dev/null
+++ b/tests/integration/actions-contact.test.ts
@@ -0,0 +1,92 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/navigation", async () => ({
+ redirect: (await import("@/tests/helpers/next-mocks")).redirect,
+}));
+vi.mock("next/dist/client/components/redirect-error", async () => ({
+ isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
+}));
+
+const { sendContactMessage } = vi.hoisted(() => ({ sendContactMessage: vi.fn(async () => {}) }));
+vi.mock("@/lib/mail", () => ({ sendContactMessage }));
+
+import { submitContactFormAction } from "@/app/[locale]/(site)/contact/actions";
+import { captureRedirect, formDataFrom } from "@/tests/helpers/next-mocks";
+
+beforeEach(() => {
+ sendContactMessage.mockClear();
+});
+
+describe("submitContactFormAction", () => {
+ it("sends a valid message and redirects to the localized success page", async () => {
+ const url = await captureRedirect(() =>
+ submitContactFormAction(
+ formDataFrom({
+ locale: "en",
+ name: "Jane Doe",
+ email: "jane@example.com",
+ message: "Hello, I would like to work together on a project.",
+ }),
+ ),
+ );
+ expect(url).toBe("/en/success");
+ expect(sendContactMessage).toHaveBeenCalledTimes(1);
+ expect(sendContactMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ locale: "en", name: "Jane Doe", email: "jane@example.com" }),
+ );
+ });
+
+ it("uses the default locale (de) and its bare success path", async () => {
+ const url = await captureRedirect(() =>
+ submitContactFormAction(
+ formDataFrom({
+ locale: "de",
+ name: "Max Mustermann",
+ email: "max@example.com",
+ message: "Ich interessiere mich fuer eine Zusammenarbeit.",
+ }),
+ ),
+ );
+ expect(url).toBe("/success");
+ });
+
+ it("redirects back with an error for an invalid email and does not send", async () => {
+ const url = await captureRedirect(() =>
+ submitContactFormAction(
+ formDataFrom({
+ locale: "en",
+ name: "Jane",
+ email: "not-an-email",
+ message: "This is a long enough message body.",
+ }),
+ ),
+ );
+ expect(url).toContain("/en/contact?error=");
+ expect(sendContactMessage).not.toHaveBeenCalled();
+ });
+
+ it("rejects a too-short message", async () => {
+ const url = await captureRedirect(() =>
+ submitContactFormAction(
+ formDataFrom({ locale: "de", name: "Jane Doe", email: "jane@example.com", message: "short" }),
+ ),
+ );
+ expect(url).toContain("/contact?error=");
+ expect(sendContactMessage).not.toHaveBeenCalled();
+ });
+
+ it("redirects with an error when delivery fails", async () => {
+ sendContactMessage.mockRejectedValueOnce(new Error("smtp down"));
+ const url = await captureRedirect(() =>
+ submitContactFormAction(
+ formDataFrom({
+ locale: "en",
+ name: "Jane Doe",
+ email: "jane@example.com",
+ message: "A perfectly valid message body here.",
+ }),
+ ),
+ );
+ expect(url).toContain("/en/contact?error=");
+ });
+});
diff --git a/tests/integration/actions-maintenance.test.ts b/tests/integration/actions-maintenance.test.ts
new file mode 100644
index 0000000..ffc0a27
--- /dev/null
+++ b/tests/integration/actions-maintenance.test.ts
@@ -0,0 +1,41 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
+vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
+vi.mock("next/dist/client/components/redirect-error", async () => ({
+ isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
+}));
+vi.mock("@/lib/admin-auth", async () => {
+ const m = await import("@/tests/helpers/next-mocks");
+ return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
+});
+
+import { updateMaintenanceModeAction } from "@/app/_admin/maintenance/actions";
+import { getMaintenanceMode } from "@/lib/app-config";
+import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
+
+beforeEach(() => {
+ resetNextMocks();
+});
+
+describe("updateMaintenanceModeAction", () => {
+ it("enables maintenance mode and redirects with a success flash", async () => {
+ const url = await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
+ expect(url).toContain("success=");
+ expect(await getMaintenanceMode()).toBe(true);
+ });
+
+ it("disables maintenance mode", async () => {
+ await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
+ await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "false" })));
+ expect(await getMaintenanceMode()).toBe(false);
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
+ expect(url).toBe("/");
+ // state unchanged
+ expect(await getMaintenanceMode()).toBe(false);
+ });
+});
diff --git a/tests/integration/actions-marquee.test.ts b/tests/integration/actions-marquee.test.ts
new file mode 100644
index 0000000..eba69ad
--- /dev/null
+++ b/tests/integration/actions-marquee.test.ts
@@ -0,0 +1,51 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
+vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
+vi.mock("next/dist/client/components/redirect-error", async () => ({
+ isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
+}));
+vi.mock("@/lib/admin-auth", async () => {
+ const m = await import("@/tests/helpers/next-mocks");
+ return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
+});
+
+import { saveMarqueeSettingsAction } from "@/app/_admin/marquee/actions";
+import { getMarqueeSettings } from "@/lib/app-config";
+import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
+
+beforeEach(() => {
+ resetNextMocks();
+});
+
+const validRows = {
+ "row1-de": "A\nB",
+ "row2-de": "C\nD",
+ "row3-de": "E\nF",
+ "row4-de": "G\nH",
+};
+
+describe("saveMarqueeSettingsAction", () => {
+ it("saves german rows and mirrors them across locales", async () => {
+ const url = await captureRedirect(() => saveMarqueeSettingsAction(formDataFrom(validRows)));
+ expect(url).toContain("success=");
+
+ const settings = await getMarqueeSettings();
+ expect(settings.locales.de.row1).toBe("A\nB");
+ expect(settings.locales.en.row1).toBe("A\nB");
+ expect(settings.locales.ar.row4).toBe("G\nH");
+ });
+
+ it("redirects with an error when a required row is empty", async () => {
+ const url = await captureRedirect(() =>
+ saveMarqueeSettingsAction(formDataFrom({ ...validRows, "row2-de": " " })),
+ );
+ expect(url).toContain("error=");
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => saveMarqueeSettingsAction(formDataFrom(validRows)));
+ expect(url).toBe("/");
+ });
+});
diff --git a/tests/integration/actions-media.test.ts b/tests/integration/actions-media.test.ts
new file mode 100644
index 0000000..de8371c
--- /dev/null
+++ b/tests/integration/actions-media.test.ts
@@ -0,0 +1,76 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
+vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
+vi.mock("next/dist/client/components/redirect-error", async () => ({
+ isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
+}));
+vi.mock("@/lib/admin-auth", async () => {
+ const m = await import("@/tests/helpers/next-mocks");
+ return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
+});
+
+import { createMediaAssetAction, deleteMediaAssetAction } from "@/app/_admin/media/actions";
+import { prisma } from "@/lib/prisma";
+import { removeManagedMediaFile } from "@/lib/media-storage";
+import { createMediaAsset, createMediaUsage } from "@/tests/helpers/factories";
+import { canManageUploads } from "@/tests/helpers/fs-capability";
+import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
+
+beforeEach(() => {
+ resetNextMocks();
+});
+
+describe("createMediaAssetAction", () => {
+ it("errors when no file is provided", async () => {
+ const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
+ expect(url).toContain("error=");
+ expect(await prisma.mediaAsset.count()).toBe(0);
+ });
+
+ it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
+ const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "pic.png", { type: "image/png" });
+ const url = await captureRedirect(() =>
+ createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
+ );
+ expect(url).toContain("success=");
+ const assets = await prisma.mediaAsset.findMany();
+ expect(assets.length).toBe(1);
+ expect(assets[0].source).toBe("UPLOAD");
+ await removeManagedMediaFile(assets[0].url);
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
+ expect(url).toBe("/");
+ });
+});
+
+describe("deleteMediaAssetAction", () => {
+ it("errors when the asset does not exist", async () => {
+ const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: "missing" })));
+ expect(url).toContain("error=");
+ });
+
+ it("refuses to delete an asset that is still in use", async () => {
+ const asset = await createMediaAsset();
+ await createMediaUsage(asset.id);
+ const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
+ expect(url).toContain("error=");
+ expect(await prisma.mediaAsset.findUnique({ where: { id: asset.id } })).not.toBeNull();
+ });
+
+ it("deletes an unused external asset", async () => {
+ const asset = await createMediaAsset({ url: "https://cdn/external.png" });
+ const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
+ expect(url).toContain("success=");
+ expect(await prisma.mediaAsset.findUnique({ where: { id: asset.id } })).toBeNull();
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: "x" })));
+ expect(url).toBe("/");
+ });
+});
diff --git a/tests/integration/actions-portfolio.test.ts b/tests/integration/actions-portfolio.test.ts
new file mode 100644
index 0000000..9df9db4
--- /dev/null
+++ b/tests/integration/actions-portfolio.test.ts
@@ -0,0 +1,222 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
+vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
+vi.mock("next/dist/client/components/redirect-error", async () => ({
+ isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
+}));
+vi.mock("@/lib/admin-auth", async () => {
+ const m = await import("@/tests/helpers/next-mocks");
+ return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
+});
+
+import {
+ deleteCategoryAction,
+ deleteProjectAction,
+ saveProjectAction,
+ upsertCategoryAction,
+} from "@/app/_admin/portfolio/actions";
+import { prisma } from "@/lib/prisma";
+import { createCategory, createProject } from "@/tests/helpers/factories";
+import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
+
+beforeEach(() => {
+ resetNextMocks();
+});
+
+function categoryForm(overrides: Record = {}) {
+ return formDataFrom({
+ slug: "branding",
+ nameAr: "الهوية",
+ nameEn: "Branding",
+ nameDe: "Branding",
+ descriptionAr: "وصف",
+ descriptionEn: "Description",
+ descriptionDe: "Beschreibung",
+ sortOrder: "1",
+ isActive: "on",
+ ...overrides,
+ });
+}
+
+function projectForm(categoryId: string, overrides: Record = {}) {
+ const assets = JSON.stringify([
+ {
+ kind: "IMAGE",
+ altAr: "ع",
+ altEn: "Alt",
+ altDe: "Alt",
+ sortOrder: 0,
+ media: { mode: "external", url: "https://cdn/asset.png", kind: "IMAGE", label: "Asset" },
+ },
+ ]);
+ const coverMedia = JSON.stringify({ mode: "external", url: "https://cdn/cover.png", kind: "IMAGE", label: "Cover" });
+ return formDataFrom({
+ categoryId,
+ slug: "case-study",
+ viewMode: "GRID",
+ titleAr: "عنوان",
+ titleEn: "Title",
+ titleDe: "Titel",
+ summaryAr: "ملخص",
+ summaryEn: "Summary",
+ summaryDe: "Zusammenfassung",
+ clientName: "Client",
+ projectYear: "2025",
+ serviceLabelAr: "خدمة",
+ serviceLabelEn: "Service",
+ serviceLabelDe: "Service",
+ previewUrl: "https://example.com",
+ sortOrder: "0",
+ isFeatured: "",
+ isPublished: "on",
+ sections: "[]",
+ assets,
+ coverMedia,
+ ...overrides,
+ });
+}
+
+describe("upsertCategoryAction", () => {
+ it("creates a category", async () => {
+ const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
+ expect(url).toContain("success=");
+ const category = await prisma.category.findUnique({ where: { slug: "branding" } });
+ expect(category?.nameEn).toBe("Branding");
+ expect(category?.isActive).toBe(true);
+ });
+
+ it("updates an existing category", async () => {
+ const existing = await createCategory({ slug: "old", nameEn: "Old" });
+ const url = await captureRedirect(() =>
+ upsertCategoryAction(categoryForm({ id: existing.id, slug: "old", nameEn: "Renamed" })),
+ );
+ expect(url).toContain("success=");
+ const category = await prisma.category.findUnique({ where: { id: existing.id } });
+ expect(category?.nameEn).toBe("Renamed");
+ });
+
+ it("reports a unique-constraint violation on duplicate slugs", async () => {
+ await createCategory({ slug: "branding" });
+ const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
+ expect(url).toContain("error=");
+ expect(decodeURIComponent(url)).toContain("eindeutig");
+ });
+
+ it("reports validation errors for an invalid slug", async () => {
+ const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "Not Valid" })));
+ expect(url).toContain("error=");
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
+ expect(url).toBe("/");
+ });
+});
+
+describe("deleteCategoryAction", () => {
+ it("refuses to delete a category that has projects", async () => {
+ const category = await createCategory();
+ await createProject({ categoryId: category.id });
+ const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
+ expect(url).toContain("error=");
+ expect(await prisma.category.findUnique({ where: { id: category.id } })).not.toBeNull();
+ });
+
+ it("deletes an empty category", async () => {
+ const category = await createCategory();
+ const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
+ expect(url).toContain("success=");
+ expect(await prisma.category.findUnique({ where: { id: category.id } })).toBeNull();
+ });
+});
+
+describe("saveProjectAction", () => {
+ it("creates a published project with cover and asset media usages", async () => {
+ const category = await createCategory();
+ const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
+ expect(url).toContain("success=");
+
+ const project = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } });
+ expect(project).not.toBeNull();
+ expect(project?.isPublished).toBe(true);
+ expect(project?.publishedAt).not.toBeNull();
+ expect(project?.coverImagePath).toBe("https://cdn/cover.png");
+
+ expect(await prisma.portfolioAsset.count({ where: { projectId: project!.id } })).toBe(1);
+ const usages = await prisma.mediaUsage.findMany({
+ where: { entityType: "portfolio-project", entityId: project!.id },
+ });
+ const usageTypes = usages.map((u) => u.usageType).sort();
+ expect(usageTypes).toEqual(["PORTFOLIO_ASSET", "PORTFOLIO_COVER"]);
+ });
+
+ it("updates an existing project and replaces its assets", async () => {
+ const category = await createCategory();
+ const created = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
+ void created;
+ const project = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } });
+
+ const url = await captureRedirect(() =>
+ saveProjectAction(projectForm(category.id, { id: project!.id, titleEn: "Updated Title" })),
+ );
+ expect(url).toContain("success=");
+ const updated = await prisma.portfolioProject.findUnique({ where: { id: project!.id } });
+ expect(updated?.titleEn).toBe("Updated Title");
+ // assets are replaced, not duplicated
+ expect(await prisma.portfolioAsset.count({ where: { projectId: project!.id } })).toBe(1);
+ });
+
+ it("keeps the original publishedAt when re-saving an already published project", async () => {
+ const category = await createCategory();
+ await captureRedirect(() => saveProjectAction(projectForm(category.id)));
+ const first = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } });
+ const originalPublishedAt = first!.publishedAt;
+
+ await captureRedirect(() => saveProjectAction(projectForm(category.id, { id: first!.id })));
+ const second = await prisma.portfolioProject.findUnique({ where: { id: first!.id } });
+ expect(second?.publishedAt?.toISOString()).toBe(originalPublishedAt?.toISOString());
+ });
+
+ it("reports validation errors and creates nothing", async () => {
+ const category = await createCategory();
+ const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { titleEn: "" })));
+ expect(url).toContain("error=");
+ expect(await prisma.portfolioProject.count()).toBe(0);
+ });
+
+ it("reports a unique-constraint violation on duplicate slugs", async () => {
+ const category = await createCategory();
+ await createProject({ categoryId: category.id, slug: "case-study" });
+ const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
+ expect(url).toContain("error=");
+ expect(decodeURIComponent(url)).toContain("eindeutig");
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => saveProjectAction(projectForm("cat")));
+ expect(url).toBe("/");
+ });
+});
+
+describe("deleteProjectAction", () => {
+ it("deletes a project and its media usages", async () => {
+ const project = await createProject();
+ const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: project.id })));
+ expect(url).toContain("success=");
+ expect(await prisma.portfolioProject.findUnique({ where: { id: project.id } })).toBeNull();
+ });
+
+ it("errors when the project does not exist", async () => {
+ const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: "missing" })));
+ expect(url).toContain("error=");
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: "x" })));
+ expect(url).toBe("/");
+ });
+});
diff --git a/tests/integration/actions-site-settings.test.ts b/tests/integration/actions-site-settings.test.ts
new file mode 100644
index 0000000..7ea067f
--- /dev/null
+++ b/tests/integration/actions-site-settings.test.ts
@@ -0,0 +1,104 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
+vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
+vi.mock("next/dist/client/components/redirect-error", async () => ({
+ isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
+}));
+vi.mock("@/lib/admin-auth", async () => {
+ const m = await import("@/tests/helpers/next-mocks");
+ return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
+});
+
+import {
+ saveSiteBrandSettingsAction,
+ saveSiteLocalizationSettingsAction,
+} from "@/app/_admin/site-settings/actions";
+import { getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
+import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
+
+beforeEach(() => {
+ resetNextMocks();
+});
+
+describe("saveSiteBrandSettingsAction", () => {
+ it("saves a normalized primary color", async () => {
+ const url = await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#123456" })));
+ expect(url).toContain("success=");
+ expect((await getSiteSettings()).brand.primaryColor).toBe("#123456");
+ });
+
+ it("falls back to the default color for invalid input", async () => {
+ await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "not-a-color" })));
+ expect((await getSiteSettings()).brand.primaryColor).toBe("#dc5a35");
+ });
+
+ it("wires an external favicon into media bindings", async () => {
+ const faviconMedia = JSON.stringify({ mode: "external", url: "https://cdn/f.svg", kind: "IMAGE", label: "F" });
+ const url = await captureRedirect(() =>
+ saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#222222", faviconMedia })),
+ );
+ expect(url).toContain("success=");
+ const bindings = await getSiteSettingsMediaBindings();
+ expect(bindings.favicon?.url).toBe("https://cdn/f.svg");
+ });
+
+ it("errors on an invalid media json payload", async () => {
+ const url = await captureRedirect(() =>
+ saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#222222", faviconMedia: "{not json" })),
+ );
+ expect(url).toContain("error=");
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#123456" })));
+ expect(url).toBe("/");
+ });
+});
+
+const localizationForm = {
+ defaultLocale: "en",
+ siteNameAr: "الموقع",
+ siteNameEn: "The Site",
+ siteNameDe: "Die Seite",
+ titleTemplateAr: "{pageTitle} | {siteName}",
+ titleTemplateEn: "{pageTitle} | {siteName}",
+ titleTemplateDe: "{pageTitle} | {siteName}",
+ siteDescriptionAr: "وصف",
+ siteDescriptionEn: "Description",
+ siteDescriptionDe: "Beschreibung",
+ subheadAr: "",
+ subheadEn: "",
+ subheadDe: "",
+};
+
+describe("saveSiteLocalizationSettingsAction", () => {
+ it("saves valid localization settings and the default locale", async () => {
+ const url = await captureRedirect(() => saveSiteLocalizationSettingsAction(formDataFrom(localizationForm)));
+ expect(url).toContain("success=");
+ const settings = await getSiteSettings();
+ expect(settings.defaultLocale).toBe("en");
+ expect(settings.locales.en.siteName).toBe("The Site");
+ });
+
+ it("requires a site name for every locale", async () => {
+ const url = await captureRedirect(() =>
+ saveSiteLocalizationSettingsAction(formDataFrom({ ...localizationForm, siteNameEn: "" })),
+ );
+ expect(url).toContain("error=");
+ });
+
+ it("requires the {pageTitle} token in every title template", async () => {
+ const url = await captureRedirect(() =>
+ saveSiteLocalizationSettingsAction(formDataFrom({ ...localizationForm, titleTemplateDe: "{siteName} only" })),
+ );
+ expect(url).toContain("error=");
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => saveSiteLocalizationSettingsAction(formDataFrom(localizationForm)));
+ expect(url).toBe("/");
+ });
+});
diff --git a/tests/integration/actions-smtp.test.ts b/tests/integration/actions-smtp.test.ts
new file mode 100644
index 0000000..c76df4b
--- /dev/null
+++ b/tests/integration/actions-smtp.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
+vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
+vi.mock("next/dist/client/components/redirect-error", async () => ({
+ isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
+}));
+vi.mock("@/lib/admin-auth", async () => {
+ const m = await import("@/tests/helpers/next-mocks");
+ return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
+});
+
+const { sendTestEmail } = vi.hoisted(() => ({ sendTestEmail: vi.fn(async () => {}) }));
+vi.mock("@/lib/mail", () => ({ sendTestEmail }));
+
+import { saveMailSettingsAction, sendTestEmailAction } from "@/app/_admin/smtp/actions";
+import { getMailSettings } from "@/lib/app-config";
+import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
+
+beforeEach(() => {
+ resetNextMocks();
+ sendTestEmail.mockClear();
+});
+
+const validForm = {
+ smtpHost: "smtp.example.com",
+ smtpPort: "465",
+ smtpUsername: "mailer",
+ smtpPassword: "secret",
+ smtpSecure: "on",
+ mailFromEmail: "from@example.com",
+ mailFromName: "Studio",
+ mailContactRecipient: "contact@example.com",
+ mailTestRecipient: "test@example.com",
+};
+
+describe("saveMailSettingsAction", () => {
+ it("persists valid settings and redirects with success", async () => {
+ const url = await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
+ expect(url).toContain("success=");
+
+ const settings = await getMailSettings();
+ expect(settings.smtp.host).toBe("smtp.example.com");
+ expect(settings.smtp.port).toBe(465);
+ expect(settings.smtp.secure).toBe(true);
+ expect(settings.recipients.contact).toBe("contact@example.com");
+ });
+
+ it("rejects an invalid port with an error flash", async () => {
+ const url = await captureRedirect(() =>
+ saveMailSettingsAction(formDataFrom({ ...validForm, smtpPort: "not-a-number" })),
+ );
+ expect(url).toContain("error=");
+ });
+
+ it("retains the existing password when the field is left blank", async () => {
+ await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
+ await captureRedirect(() =>
+ saveMailSettingsAction(formDataFrom({ ...validForm, smtpPassword: "" })),
+ );
+ const settings = await getMailSettings();
+ expect(settings.smtp.password).toBe("secret");
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
+ expect(url).toBe("/");
+ });
+});
+
+describe("sendTestEmailAction", () => {
+ it("sends a test email and redirects with success", async () => {
+ const url = await captureRedirect(() => sendTestEmailAction());
+ expect(sendTestEmail).toHaveBeenCalledTimes(1);
+ expect(url).toContain("success=");
+ });
+
+ it("redirects with an error when sending fails", async () => {
+ sendTestEmail.mockRejectedValueOnce(new Error("SMTP host is required."));
+ const url = await captureRedirect(() => sendTestEmailAction());
+ expect(url).toContain("error=");
+ });
+
+ it("redirects unauthenticated callers to the admin root", async () => {
+ adminAuth.authenticated = false;
+ const url = await captureRedirect(() => sendTestEmailAction());
+ expect(url).toBe("/");
+ expect(sendTestEmail).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/integration/admin-auth.test.ts b/tests/integration/admin-auth.test.ts
new file mode 100644
index 0000000..cf4b664
--- /dev/null
+++ b/tests/integration/admin-auth.test.ts
@@ -0,0 +1,81 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/headers", () => ({
+ headers: async () => new Headers({ "x-forwarded-for": "203.0.113.7" }),
+ cookies: async () => ({ get: () => undefined, set: () => {}, delete: () => {} }),
+}));
+
+import {
+ getAdminLockState,
+ isAdminAuthConfigured,
+ isPasswordValid,
+ registerFailedAdminAttempt,
+ resetAdminFailedAttempts,
+} from "@/lib/admin-auth";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("admin auth configuration", () => {
+ it("is configured only when both password and secret are set", () => {
+ vi.stubEnv("ADMIN_PASSWORD", "");
+ vi.stubEnv("ADMIN_AUTH_SECRET", "");
+ expect(isAdminAuthConfigured()).toBe(false);
+
+ vi.stubEnv("ADMIN_PASSWORD", "pw");
+ vi.stubEnv("ADMIN_AUTH_SECRET", "");
+ expect(isAdminAuthConfigured()).toBe(false);
+
+ vi.stubEnv("ADMIN_PASSWORD", "pw");
+ vi.stubEnv("ADMIN_AUTH_SECRET", "secret");
+ expect(isAdminAuthConfigured()).toBe(true);
+ });
+});
+
+describe("isPasswordValid", () => {
+ it("returns false when auth is not configured", () => {
+ vi.stubEnv("ADMIN_PASSWORD", "");
+ vi.stubEnv("ADMIN_AUTH_SECRET", "");
+ expect(isPasswordValid("anything")).toBe(false);
+ });
+
+ it("accepts the correct password and rejects wrong ones", () => {
+ vi.stubEnv("ADMIN_PASSWORD", "s3cret-password");
+ vi.stubEnv("ADMIN_AUTH_SECRET", "hmac-secret");
+ expect(isPasswordValid("s3cret-password")).toBe(true);
+ expect(isPasswordValid("wrong")).toBe(false);
+ expect(isPasswordValid("s3cret-passwordX")).toBe(false); // length mismatch
+ });
+});
+
+describe("login lockout", () => {
+ it("locks the IP after the failed-attempt threshold", async () => {
+ expect((await getAdminLockState()).locked).toBe(false);
+
+ for (let i = 0; i < 4; i += 1) {
+ const state = await registerFailedAdminAttempt();
+ expect(state.locked).toBe(false);
+ }
+ expect((await getAdminLockState()).locked).toBe(false);
+
+ const fifth = await registerFailedAdminAttempt();
+ expect(fifth.locked).toBe(true);
+ expect(fifth.remainingSeconds).toBeGreaterThan(0);
+
+ const lockState = await getAdminLockState();
+ expect(lockState.locked).toBe(true);
+ expect(lockState.remainingSeconds).toBeGreaterThan(0);
+ expect(lockState.remainingSeconds).toBeLessThanOrEqual(15 * 60);
+ });
+
+ it("clears the lock on reset", async () => {
+ for (let i = 0; i < 5; i += 1) {
+ await registerFailedAdminAttempt();
+ }
+ expect((await getAdminLockState()).locked).toBe(true);
+
+ await resetAdminFailedAttempts();
+ expect((await getAdminLockState()).locked).toBe(false);
+ });
+});
diff --git a/tests/integration/api-routes.test.ts b/tests/integration/api-routes.test.ts
new file mode 100644
index 0000000..a06b6aa
--- /dev/null
+++ b/tests/integration/api-routes.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { GET as healthGet } from "@/app/api/health/route";
+import { GET as defaultLocaleGet } from "@/app/api/site/default-locale/route";
+import { setMaintenanceMode, updateSiteSettings, getSiteSettings } from "@/lib/app-config";
+import { prisma } from "@/lib/prisma";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("GET /api/health", () => {
+ it("reports ok when the database responds", async () => {
+ const response = await healthGet();
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.status).toBe("ok");
+ expect(body.checks.database).toBe("up");
+ expect(typeof body.timestamp).toBe("string");
+ });
+
+ it("reports degraded (503) when the database query throws", async () => {
+ vi.spyOn(prisma, "$queryRaw").mockRejectedValueOnce(new Error("db down"));
+ const response = await healthGet();
+ expect(response.status).toBe(503);
+ const body = await response.json();
+ expect(body.status).toBe("degraded");
+ expect(body.checks.database).toBe("down");
+ });
+});
+
+describe("GET /api/site/default-locale", () => {
+ it("returns the runtime default locale and maintenance flag with no-store", async () => {
+ const response = await defaultLocaleGet();
+ expect(response.headers.get("Cache-Control")).toBe("no-store, max-age=0");
+ const body = await response.json();
+ expect(body.defaultLocale).toBe("de");
+ expect(body.maintenanceEnabled).toBe(false);
+ });
+
+ it("reflects updated settings and maintenance state", async () => {
+ const settings = await getSiteSettings();
+ settings.defaultLocale = "ar";
+ await updateSiteSettings(settings);
+ await setMaintenanceMode(true);
+
+ const response = await defaultLocaleGet();
+ const body = await response.json();
+ expect(body.defaultLocale).toBe("ar");
+ expect(body.maintenanceEnabled).toBe(true);
+ });
+});
diff --git a/tests/integration/app-config.test.ts b/tests/integration/app-config.test.ts
new file mode 100644
index 0000000..2fcf6fd
--- /dev/null
+++ b/tests/integration/app-config.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_SITE_NAME,
+ MAINTENANCE_MODE_KEY,
+ SITE_NAME_KEY,
+ SITE_SETTINGS_ENTITY_ID,
+ SITE_SETTINGS_ENTITY_TYPE,
+ SITE_SETTINGS_FAVICON_FIELD_KEY,
+ SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
+ buildDefaultMailSettings,
+ buildDefaultMarqueeSettings,
+ getMailSettings,
+ getMaintenanceMode,
+ getMarqueeSettings,
+ getSiteSettings,
+ getSiteSettingsMediaBindings,
+ setMaintenanceMode,
+ updateMailSettings,
+ updateMarqueeSettings,
+ updateSiteSettings,
+} from "@/lib/app-config";
+import { prisma } from "@/lib/prisma";
+import { createMediaAsset } from "@/tests/helpers/factories";
+
+describe("maintenance mode", () => {
+ it("defaults to false when unset", async () => {
+ expect(await getMaintenanceMode()).toBe(false);
+ });
+
+ it("persists and reads back the enabled flag", async () => {
+ await setMaintenanceMode(true);
+ expect(await getMaintenanceMode()).toBe(true);
+ const row = await prisma.appConfig.findUnique({ where: { key: MAINTENANCE_MODE_KEY } });
+ expect(row?.value).toBe("true");
+ await setMaintenanceMode(false);
+ expect(await getMaintenanceMode()).toBe(false);
+ });
+});
+
+describe("site settings", () => {
+ it("returns defaults (with fallback name) when nothing stored", async () => {
+ const settings = await getSiteSettings();
+ expect(settings.defaultLocale).toBe("de");
+ expect(settings.locales.en.siteName).toBe(DEFAULT_SITE_NAME);
+ });
+
+ it("uses the stored siteName key as the fallback name", async () => {
+ await prisma.appConfig.create({ data: { key: SITE_NAME_KEY, value: "My Studio" } });
+ const settings = await getSiteSettings();
+ expect(settings.locales.ar.siteName).toBe("My Studio");
+ });
+
+ it("round-trips an updated settings object", async () => {
+ const next = await getSiteSettings();
+ next.defaultLocale = "ar";
+ next.brand.primaryColor = "#123456";
+ next.locales.en.siteName = "Updated EN";
+ await updateSiteSettings(next);
+
+ const reloaded = await getSiteSettings();
+ expect(reloaded.defaultLocale).toBe("ar");
+ expect(reloaded.brand.primaryColor).toBe("#123456");
+ expect(reloaded.locales.en.siteName).toBe("Updated EN");
+ });
+});
+
+describe("mail settings", () => {
+ it("returns defaults when unset", async () => {
+ expect(await getMailSettings()).toEqual(buildDefaultMailSettings());
+ });
+
+ it("round-trips stored mail settings", async () => {
+ const next = buildDefaultMailSettings();
+ next.smtp.host = "smtp.test";
+ next.smtp.port = 465;
+ next.sender.email = "from@test";
+ next.recipients.contact = "c@test";
+ await updateMailSettings(next);
+
+ const reloaded = await getMailSettings();
+ expect(reloaded.smtp.host).toBe("smtp.test");
+ expect(reloaded.smtp.port).toBe(465);
+ expect(reloaded.recipients.contact).toBe("c@test");
+ });
+});
+
+describe("marquee settings", () => {
+ it("returns defaults when unset", async () => {
+ const settings = await getMarqueeSettings();
+ expect(settings.locales.de.row1).toBe(buildDefaultMarqueeSettings().locales.de.row1);
+ });
+
+ it("stores german-synced values", async () => {
+ const next = buildDefaultMarqueeSettings();
+ next.locales.de.row1 = "GERMAN ROW";
+ next.locales.en.row1 = "will be overwritten";
+ await updateMarqueeSettings(next);
+
+ const reloaded = await getMarqueeSettings();
+ expect(reloaded.locales.de.row1).toBe("GERMAN ROW");
+ expect(reloaded.locales.en.row1).toBe("GERMAN ROW");
+ expect(reloaded.locales.ar.row1).toBe("GERMAN ROW");
+ });
+});
+
+describe("getSiteSettingsMediaBindings", () => {
+ it("returns nulls when there are no usages", async () => {
+ const bindings = await getSiteSettingsMediaBindings();
+ expect(bindings).toEqual({ siteLogoLight: null, siteLogoDark: null, favicon: null, defaultOgImage: null });
+ });
+
+ it("maps media usages to their field bindings", async () => {
+ const logo = await createMediaAsset({ url: "https://cdn/logo.png" });
+ const favicon = await createMediaAsset({ url: "https://cdn/favicon.svg" });
+ await prisma.mediaUsage.create({
+ data: {
+ assetId: logo.id,
+ usageType: "GENERIC",
+ entityType: SITE_SETTINGS_ENTITY_TYPE,
+ entityId: SITE_SETTINGS_ENTITY_ID,
+ fieldKey: SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
+ },
+ });
+ await prisma.mediaUsage.create({
+ data: {
+ assetId: favicon.id,
+ usageType: "GENERIC",
+ entityType: SITE_SETTINGS_ENTITY_TYPE,
+ entityId: SITE_SETTINGS_ENTITY_ID,
+ fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY,
+ },
+ });
+
+ const bindings = await getSiteSettingsMediaBindings();
+ expect(bindings.siteLogoLight?.assetId).toBe(logo.id);
+ expect(bindings.siteLogoLight?.url).toBe("https://cdn/logo.png");
+ expect(bindings.favicon?.assetId).toBe(favicon.id);
+ expect(bindings.favicon?.version).toMatch(/\d{4}-\d{2}-\d{2}T/); // updatedAt ISO string
+ expect(bindings.siteLogoDark).toBeNull();
+ });
+});
diff --git a/tests/integration/media-service.test.ts b/tests/integration/media-service.test.ts
new file mode 100644
index 0000000..1ed8d63
--- /dev/null
+++ b/tests/integration/media-service.test.ts
@@ -0,0 +1,120 @@
+import { readFile } from "fs/promises";
+
+import { describe, expect, it } from "vitest";
+
+import { resolveMediaSelection } from "@/lib/media-service";
+import { resolveMediaUploadPath } from "@/lib/media-storage";
+import { prisma } from "@/lib/prisma";
+import { createMediaAsset } from "@/tests/helpers/factories";
+import { canManageUploads } from "@/tests/helpers/fs-capability";
+
+describe("resolveMediaSelection — library mode", () => {
+ it("returns the referenced asset", async () => {
+ const asset = await createMediaAsset({ url: "https://cdn/lib.png" });
+ const result = await resolveMediaSelection({
+ media: { mode: "library", assetId: asset.id, url: "", label: "", kind: "IMAGE" },
+ uploadFile: null,
+ folder: "covers",
+ fallbackLabel: "Cover",
+ required: false,
+ });
+ expect(result.assetId).toBe(asset.id);
+ expect(result.url).toBe("https://cdn/lib.png");
+ });
+
+ it("throws when the referenced asset is missing", async () => {
+ await expect(
+ resolveMediaSelection({
+ media: { mode: "library", assetId: "nope", url: "", label: "", kind: "IMAGE" },
+ uploadFile: null,
+ folder: "covers",
+ fallbackLabel: "Cover",
+ required: true,
+ }),
+ ).rejects.toThrow(/not found/i);
+ });
+});
+
+describe("resolveMediaSelection — external mode", () => {
+ it("creates a new external asset from the url", async () => {
+ const result = await resolveMediaSelection({
+ media: { mode: "external", assetId: "", url: "https://cdn/new/photo.png", label: "Photo", kind: "IMAGE" },
+ uploadFile: null,
+ folder: "covers",
+ fallbackLabel: "Cover",
+ required: true,
+ });
+ expect(result.createdAssetId).toBeTruthy();
+ expect(result.url).toBe("https://cdn/new/photo.png");
+
+ const stored = await prisma.mediaAsset.findUnique({ where: { id: result.assetId! } });
+ expect(stored?.source).toBe("EXTERNAL");
+ expect(stored?.fileName).toBe("photo.png");
+ expect(stored?.label).toBe("Photo");
+ });
+
+ it("returns empty selection for a not-required empty url", async () => {
+ const result = await resolveMediaSelection({
+ media: { mode: "external", assetId: "", url: "", label: "", kind: "IMAGE" },
+ uploadFile: null,
+ folder: "covers",
+ fallbackLabel: "Cover",
+ required: false,
+ });
+ expect(result.assetId).toBeNull();
+ expect(result.url).toBe("");
+ });
+});
+
+describe("resolveMediaSelection — missing configuration", () => {
+ it("throws when required and no media object is present", async () => {
+ await expect(
+ resolveMediaSelection({ media: undefined, uploadFile: null, folder: "covers", fallbackLabel: "L", required: true }),
+ ).rejects.toThrow(/missing/i);
+ });
+
+ it("returns empty selection when not required and no media object is present", async () => {
+ const result = await resolveMediaSelection({
+ media: undefined,
+ uploadFile: null,
+ folder: "covers",
+ fallbackLabel: "L",
+ required: false,
+ });
+ expect(result.assetId).toBeNull();
+ });
+
+ it("throws for a required upload with no file", async () => {
+ await expect(
+ resolveMediaSelection({
+ media: { mode: "upload", assetId: "", url: "", label: "", kind: "IMAGE" },
+ uploadFile: null,
+ folder: "covers",
+ fallbackLabel: "L",
+ required: true,
+ }),
+ ).rejects.toThrow(/required/i);
+ });
+});
+
+describe("resolveMediaSelection — upload mode (filesystem)", () => {
+ it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
+ const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", { type: "image/png" });
+ const result = await resolveMediaSelection({
+ media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
+ uploadFile: file,
+ folder: "tests",
+ fallbackLabel: "L",
+ required: true,
+ });
+ expect(result.uploadedUrl).toBeTruthy();
+ const stored = await prisma.mediaAsset.findUnique({ where: { id: result.assetId! } });
+ expect(stored?.source).toBe("UPLOAD");
+ // File actually written to disk
+ const bytes = await readFile(resolveMediaUploadPath(result.url));
+ expect(bytes.length).toBeGreaterThan(0);
+ // cleanup
+ const { removeManagedMediaFile } = await import("@/lib/media-storage");
+ await removeManagedMediaFile(result.url);
+ });
+});
diff --git a/tests/integration/media.test.ts b/tests/integration/media.test.ts
new file mode 100644
index 0000000..83afa1b
--- /dev/null
+++ b/tests/integration/media.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ countMediaUsageReferences,
+ createMediaAsset,
+ deleteEntityMediaUsages,
+ getAdminMediaAssets,
+ getMediaAssetById,
+ getMediaOptions,
+ getPortfolioMediaBindings,
+ replaceEntityMediaUsages,
+} from "@/lib/media";
+import { prisma } from "@/lib/prisma";
+import { createMediaAsset as seedAsset } from "@/tests/helpers/factories";
+
+describe("createMediaAsset / getMediaAssetById", () => {
+ it("creates and reads back an asset with usages", async () => {
+ const created = await createMediaAsset({
+ source: "EXTERNAL",
+ kind: "IMAGE",
+ url: "https://cdn/x.png",
+ fileName: "x.png",
+ label: "X",
+ });
+ const found = await getMediaAssetById(created.id);
+ expect(found?.url).toBe("https://cdn/x.png");
+ expect(found?.usages).toEqual([]);
+ });
+
+ it("returns null for a missing asset", async () => {
+ expect(await getMediaAssetById("nope")).toBeNull();
+ });
+});
+
+describe("getMediaOptions", () => {
+ it("filters by kind", async () => {
+ await seedAsset({ kind: "IMAGE" });
+ await seedAsset({ kind: "DOCUMENT" });
+ const images = await getMediaOptions({ kind: "IMAGE" });
+ expect(images.every((a) => a.kind === "IMAGE")).toBe(true);
+ const all = await getMediaOptions();
+ expect(all.length).toBe(2);
+ });
+});
+
+describe("getAdminMediaAssets", () => {
+ it("returns newest first with usage details", async () => {
+ const a = await seedAsset();
+ await createMediaUsageFor(a.id);
+ const list = await getAdminMediaAssets();
+ expect(list.length).toBe(1);
+ expect(list[0].usages.length).toBe(1);
+ });
+});
+
+describe("replaceEntityMediaUsages", () => {
+ it("replaces existing usages transactionally", async () => {
+ const a1 = await seedAsset();
+ const a2 = await seedAsset();
+
+ await replaceEntityMediaUsages({
+ entityType: "portfolio-project",
+ entityId: "p1",
+ usages: [{ assetId: a1.id, usageType: "PORTFOLIO_COVER", fieldKey: "cover" }],
+ });
+ expect(await countMediaUsageReferences(a1.id)).toBe(1);
+
+ await replaceEntityMediaUsages({
+ entityType: "portfolio-project",
+ entityId: "p1",
+ usages: [{ assetId: a2.id, usageType: "PORTFOLIO_COVER", fieldKey: "cover" }],
+ });
+ expect(await countMediaUsageReferences(a1.id)).toBe(0);
+ expect(await countMediaUsageReferences(a2.id)).toBe(1);
+ });
+
+ it("clears usages when given an empty list", async () => {
+ const a1 = await seedAsset();
+ await replaceEntityMediaUsages({
+ entityType: "portfolio-project",
+ entityId: "p2",
+ usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "a" }],
+ });
+ await replaceEntityMediaUsages({ entityType: "portfolio-project", entityId: "p2", usages: [] });
+ expect(await countMediaUsageReferences(a1.id)).toBe(0);
+ });
+});
+
+describe("deleteEntityMediaUsages", () => {
+ it("removes only the target entity's usages", async () => {
+ const a1 = await seedAsset();
+ await replaceEntityMediaUsages({
+ entityType: "portfolio-project",
+ entityId: "keep",
+ usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "a" }],
+ });
+ await replaceEntityMediaUsages({
+ entityType: "portfolio-project",
+ entityId: "drop",
+ usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "b" }],
+ });
+ await deleteEntityMediaUsages("portfolio-project", "drop");
+ expect(await countMediaUsageReferences(a1.id)).toBe(1);
+ });
+});
+
+describe("getPortfolioMediaBindings", () => {
+ it("routes usages into cover / section / asset buckets", async () => {
+ const cover = await seedAsset();
+ const section = await seedAsset();
+ const asset = await seedAsset();
+
+ await prisma.mediaUsage.createMany({
+ data: [
+ { assetId: cover.id, usageType: "PORTFOLIO_COVER", entityType: "portfolio-project", entityId: "proj", fieldKey: "cover" },
+ { assetId: section.id, usageType: "PORTFOLIO_SECTION", entityType: "portfolio-project", entityId: "proj", fieldKey: "sec_1" },
+ { assetId: asset.id, usageType: "PORTFOLIO_ASSET", entityType: "portfolio-project", entityId: "proj", fieldKey: "ast_1" },
+ ],
+ });
+
+ const bindings = await getPortfolioMediaBindings("proj");
+ expect(bindings.coverAssetId).toBe(cover.id);
+ expect(bindings.sectionAssetIds.sec_1).toBe(section.id);
+ expect(bindings.assetIds.ast_1).toBe(asset.id);
+ });
+
+ it("returns empty bindings for an unknown project", async () => {
+ const bindings = await getPortfolioMediaBindings("missing");
+ expect(bindings).toEqual({ coverAssetId: null, sectionAssetIds: {}, assetIds: {} });
+ });
+});
+
+async function createMediaUsageFor(assetId: string) {
+ await prisma.mediaUsage.create({
+ data: { assetId, usageType: "GENERIC", entityType: "e", entityId: "1", fieldKey: "f" },
+ });
+}
diff --git a/tests/integration/portfolio.test.ts b/tests/integration/portfolio.test.ts
new file mode 100644
index 0000000..32ba97e
--- /dev/null
+++ b/tests/integration/portfolio.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getActivePortfolioCategories,
+ getActivePortfolioCategoryBySlug,
+ getAdminPortfolioCategories,
+ getAdminPortfolioProjectById,
+ getAdminPortfolioProjects,
+ getPublishedPortfolioProjectBySlug,
+ getPublishedPortfolioProjects,
+} from "@/lib/portfolio";
+import { prisma } from "@/lib/prisma";
+import {
+ createAsset,
+ createCategory,
+ createMediaAsset,
+ createProject,
+ createSection,
+} from "@/tests/helpers/factories";
+
+describe("categories", () => {
+ it("lists admin categories with project counts, ordered", async () => {
+ const a = await createCategory({ slug: "a", sortOrder: 2 });
+ await createCategory({ slug: "b", sortOrder: 1 });
+ await createProject({ categoryId: a.id });
+
+ const categories = await getAdminPortfolioCategories();
+ expect(categories.map((c) => c.slug)).toEqual(["b", "a"]); // sortOrder asc
+ expect(categories.find((c) => c.slug === "a")?.projectCount).toBe(1);
+ expect(categories.find((c) => c.slug === "b")?.projectCount).toBe(0);
+ });
+
+ it("returns only active categories publicly", async () => {
+ await createCategory({ slug: "on", isActive: true });
+ await createCategory({ slug: "off", isActive: false });
+ const active = await getActivePortfolioCategories();
+ expect(active.map((c) => c.slug)).toEqual(["on"]);
+ });
+
+ it("finds an active category by slug and ignores inactive ones", async () => {
+ await createCategory({ slug: "visible", isActive: true });
+ await createCategory({ slug: "hidden", isActive: false });
+ expect((await getActivePortfolioCategoryBySlug("visible"))?.slug).toBe("visible");
+ expect(await getActivePortfolioCategoryBySlug("hidden")).toBeNull();
+ });
+});
+
+describe("admin projects", () => {
+ it("filters by status and category", async () => {
+ const cat = await createCategory();
+ await createProject({ categoryId: cat.id, slug: "pub", isPublished: true });
+ await createProject({ categoryId: cat.id, slug: "draft", isPublished: false });
+
+ const published = await getAdminPortfolioProjects({ status: "published" });
+ expect(published.map((p) => p.slug)).toEqual(["pub"]);
+
+ const drafts = await getAdminPortfolioProjects({ status: "draft" });
+ expect(drafts.map((p) => p.slug)).toEqual(["draft"]);
+
+ const byCategory = await getAdminPortfolioProjects({ categoryId: cat.id });
+ expect(byCategory.length).toBe(2);
+ });
+
+ it("maps localized content and nested sections/assets", async () => {
+ const project = await createProject({ slug: "mapped" });
+ await createSection(project.id, { titleEn: "Intro" });
+ await createAsset(project.id, { altEn: "Cover" });
+
+ const detail = await getAdminPortfolioProjectById(project.id);
+ expect(detail?.title.en).toBe("Title");
+ expect(detail?.sections[0].title.en).toBe("Intro");
+ expect(detail?.assets[0].alt.en).toBe("Cover");
+ });
+
+ it("attaches media bindings to a project fetched by id", async () => {
+ const project = await createProject();
+ const cover = await createMediaAsset();
+ await prisma.mediaUsage.create({
+ data: {
+ assetId: cover.id,
+ usageType: "PORTFOLIO_COVER",
+ entityType: "portfolio-project",
+ entityId: project.id,
+ fieldKey: "cover",
+ },
+ });
+ const detail = await getAdminPortfolioProjectById(project.id);
+ expect(detail?.coverMediaAssetId).toBe(cover.id);
+ });
+
+ it("returns null for a missing project id", async () => {
+ expect(await getAdminPortfolioProjectById("missing")).toBeNull();
+ });
+});
+
+describe("published projects", () => {
+ it("returns only published projects in active categories", async () => {
+ const activeCat = await createCategory({ isActive: true });
+ const inactiveCat = await createCategory({ isActive: false });
+ await createProject({ categoryId: activeCat.id, slug: "shown", isPublished: true });
+ await createProject({ categoryId: activeCat.id, slug: "hidden-draft", isPublished: false });
+ await createProject({ categoryId: inactiveCat.id, slug: "hidden-cat", isPublished: true });
+
+ const projects = await getPublishedPortfolioProjects();
+ expect(projects.map((p) => p.slug)).toEqual(["shown"]);
+ });
+
+ it("filters published projects by category slug", async () => {
+ const catA = await createCategory({ slug: "cat-a", isActive: true });
+ const catB = await createCategory({ slug: "cat-b", isActive: true });
+ await createProject({ categoryId: catA.id, slug: "in-a", isPublished: true });
+ await createProject({ categoryId: catB.id, slug: "in-b", isPublished: true });
+
+ const projects = await getPublishedPortfolioProjects({ categorySlug: "cat-a" });
+ expect(projects.map((p) => p.slug)).toEqual(["in-a"]);
+ });
+
+ it("finds a published project by slug and hides drafts", async () => {
+ await createProject({ slug: "live", isPublished: true });
+ await createProject({ slug: "wip", isPublished: false });
+ expect((await getPublishedPortfolioProjectBySlug("live"))?.slug).toBe("live");
+ expect(await getPublishedPortfolioProjectBySlug("wip")).toBeNull();
+ });
+});
+
+describe("referential integrity", () => {
+ it("restricts deleting a category that still has projects", async () => {
+ const cat = await createCategory();
+ await createProject({ categoryId: cat.id });
+ await expect(prisma.category.delete({ where: { id: cat.id } })).rejects.toThrow();
+ });
+
+ it("cascades section and asset deletion when a project is removed", async () => {
+ const project = await createProject();
+ await createSection(project.id);
+ await createAsset(project.id);
+ await prisma.portfolioProject.delete({ where: { id: project.id } });
+ expect(await prisma.portfolioSection.count()).toBe(0);
+ expect(await prisma.portfolioAsset.count()).toBe(0);
+ });
+});
diff --git a/tests/portfolio-storage.test.ts b/tests/portfolio-storage.test.ts
index 965b3cd..20aa6e0 100644
--- a/tests/portfolio-storage.test.ts
+++ b/tests/portfolio-storage.test.ts
@@ -10,6 +10,7 @@ import {
resolveMediaUploadPath,
sanitizeBaseName,
} from "../lib/media-storage";
+import { canManageUploads } from "./helpers/fs-capability";
const createdFiles: string[] = [];
@@ -39,7 +40,7 @@ describe("media storage helpers", () => {
expect(resolvedPath.endsWith(path.join("assets", "test.svg"))).toBe(true);
});
- it("removes a managed file from disk", async () => {
+ it.skipIf(!canManageUploads)("removes a managed file from disk", async () => {
const relativePath = `/uploads/media/tests/${Date.now()}-temp.txt`;
const absolutePath = resolveMediaUploadPath(relativePath);
diff --git a/tests/unit/admin-feedback.test.ts b/tests/unit/admin-feedback.test.ts
new file mode 100644
index 0000000..071a998
--- /dev/null
+++ b/tests/unit/admin-feedback.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+
+import { readFlash, withFlash } from "@/lib/admin-feedback";
+
+describe("withFlash", () => {
+ it("returns the plain path when there are no messages", () => {
+ expect(withFlash("/admin/smtp", {})).toBe("/admin/smtp");
+ });
+
+ it("appends a success message", () => {
+ expect(withFlash("/admin/smtp", { success: "Saved." })).toBe("/admin/smtp?success=Saved.");
+ });
+
+ it("appends an error message", () => {
+ expect(withFlash("/admin/smtp", { error: "Nope." })).toBe("/admin/smtp?error=Nope.");
+ });
+
+ it("appends both and url-encodes values", () => {
+ const result = withFlash("/admin/smtp", { success: "a b", error: "x&y" });
+ const params = new URL(result, "http://local").searchParams;
+ expect(params.get("success")).toBe("a b");
+ expect(params.get("error")).toBe("x&y");
+ });
+});
+
+describe("readFlash", () => {
+ it("reads success and error from resolved search params", () => {
+ expect(readFlash({ success: "ok", error: "bad" })).toEqual({ success: "ok", error: "bad" });
+ });
+
+ it("returns undefined fields when params are missing", () => {
+ expect(readFlash(undefined)).toEqual({ success: undefined, error: undefined });
+ expect(readFlash(null)).toEqual({ success: undefined, error: undefined });
+ expect(readFlash({})).toEqual({ success: undefined, error: undefined });
+ });
+});
diff --git a/tests/unit/admin-navigation.test.ts b/tests/unit/admin-navigation.test.ts
new file mode 100644
index 0000000..ad6b305
--- /dev/null
+++ b/tests/unit/admin-navigation.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+
+import { getAdminNavigation } from "@/lib/admin-navigation";
+
+const copy = {
+ overview: "Overview",
+ maintenance: "Maintenance",
+ uiKit: "UI Kit",
+ portfolio: "Portfolio",
+ media: "Media",
+ siteSettings: "Site Settings",
+ brandSettings: "Brand",
+ localizationSettings: "Localization",
+ marquee: "Marquee",
+ smtp: "SMTP",
+};
+
+describe("getAdminNavigation", () => {
+ it("returns the full set of top-level sections", () => {
+ const nav = getAdminNavigation(copy, "overview");
+ const labels = nav.map((item) => item.label);
+ expect(labels).toEqual([
+ "Overview",
+ "Maintenance",
+ "UI Kit",
+ "Media",
+ "Site Settings",
+ "Marquee",
+ "SMTP",
+ "Portfolio",
+ ]);
+ });
+
+ it("marks the active top-level section", () => {
+ const nav = getAdminNavigation(copy, "smtp");
+ expect(nav.find((item) => item.label === "SMTP")?.active).toBe(true);
+ expect(nav.find((item) => item.label === "Overview")?.active).toBe(false);
+ });
+
+ it("expands site-settings and marks the active child", () => {
+ const nav = getAdminNavigation(copy, "site-settings", undefined, "localization");
+ const siteSettings = nav.find((item) => item.label === "Site Settings");
+ expect(siteSettings?.expanded).toBe(true);
+ expect(siteSettings?.active).toBe(false); // has a child selected
+ const localization = siteSettings?.children?.find((c) => c.label === "Localization");
+ expect(localization?.active).toBe(true);
+ });
+
+ it("marks the parent active when no child is selected", () => {
+ const nav = getAdminNavigation(copy, "site-settings");
+ const siteSettings = nav.find((item) => item.label === "Site Settings");
+ expect(siteSettings?.active).toBe(true);
+ expect(siteSettings?.expanded).toBe(true);
+ });
+
+ it("maps portfolio children and de-duplicates hrefs", () => {
+ const nav = getAdminNavigation(copy, "portfolio", "projects");
+ const portfolio = nav.find((item) => item.label === "Portfolio");
+ expect(portfolio?.expanded).toBe(true);
+ const hrefs = portfolio?.children?.map((c) => c.href) ?? [];
+ expect(new Set(hrefs).size).toBe(hrefs.length); // unique
+ const projects = portfolio?.children?.find((c) => c.label === "Projects");
+ expect(projects?.active).toBe(true);
+ });
+
+ it("activates the new-project child", () => {
+ const nav = getAdminNavigation(copy, "portfolio", "new-project");
+ const portfolio = nav.find((item) => item.label === "Portfolio");
+ expect(portfolio?.children?.find((c) => c.label === "Add Project")?.active).toBe(true);
+ });
+
+ it("falls back to default child labels when copy omits them", () => {
+ const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
+ const nav = getAdminNavigation(minimal, "overview");
+ const siteSettings = nav.find((item) => item.label === "Site Settings");
+ expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization"]);
+ expect(nav.find((item) => item.href.endsWith("/marquee"))?.label).toBe("Marquee");
+ expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
+ });
+
+ it("gives every item an icon and href", () => {
+ const nav = getAdminNavigation(copy, "overview");
+ for (const item of nav) {
+ expect(item.icon).toBeTruthy();
+ expect(typeof item.href).toBe("string");
+ }
+ });
+});
diff --git a/tests/unit/admin-routing.test.ts b/tests/unit/admin-routing.test.ts
new file mode 100644
index 0000000..15021c2
--- /dev/null
+++ b/tests/unit/admin-routing.test.ts
@@ -0,0 +1,147 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ buildAdminUrl,
+ buildSiteUrl,
+ fromDevelopmentAdminPath,
+ getAdminAppPath,
+ getAdminBaseUrl,
+ getAdminHost,
+ getRequestHostname,
+ getSiteBaseUrl,
+ getSiteHost,
+ hasDedicatedAdminHost,
+ INTERNAL_ADMIN_PREFIX,
+ isAdminHost,
+ isDevelopmentAdminPath,
+ isInternalAdminPath,
+ isLegacyAdminPath,
+ toInternalAdminPath,
+} from "@/lib/admin-routing";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("getRequestHostname", () => {
+ it("prefers the first non-empty candidate", () => {
+ expect(getRequestHostname("root.mohfarawati.de", "internal")).toBe("root.mohfarawati.de");
+ });
+
+ it("strips ports and takes the first comma-separated proxy value", () => {
+ expect(getRequestHostname(undefined, "root.mohfarawati.de:443, proxy")).toBe("root.mohfarawati.de");
+ });
+
+ it("lowercases the hostname", () => {
+ expect(getRequestHostname("ROOT.MohFarawati.de")).toBe("root.mohfarawati.de");
+ });
+
+ it("falls back to empty string when nothing matches", () => {
+ expect(getRequestHostname(undefined, null, "")).toBe("");
+ });
+});
+
+describe("host configuration", () => {
+ it("defaults admin and site hosts", () => {
+ expect(getAdminHost()).toBe("root.mohfarawati.de");
+ expect(getSiteHost()).toBe("mohfarawati.de");
+ expect(hasDedicatedAdminHost()).toBe(true);
+ });
+
+ it("reads ADMIN_HOST override and normalizes case/whitespace", () => {
+ vi.stubEnv("ADMIN_HOST", " Admin.Example.COM ");
+ expect(getAdminHost()).toBe("admin.example.com");
+ });
+
+ it("derives the site host from NEXT_PUBLIC_SITE_URL", () => {
+ vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.org/some/path");
+ expect(getSiteHost()).toBe("example.org");
+ });
+
+ it("hasDedicatedAdminHost is false when admin and site hosts match", () => {
+ vi.stubEnv("ADMIN_HOST", "example.com");
+ vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
+ expect(hasDedicatedAdminHost()).toBe(false);
+ });
+
+ it("isAdminHost compares against the configured admin host", () => {
+ expect(isAdminHost("root.mohfarawati.de")).toBe(true);
+ expect(isAdminHost("mohfarawati.de")).toBe(false);
+ });
+});
+
+describe("path predicates", () => {
+ it("recognizes legacy /root paths", () => {
+ expect(isLegacyAdminPath("/root")).toBe(true);
+ expect(isLegacyAdminPath("/root/portfolio")).toBe(true);
+ expect(isLegacyAdminPath("/rooting")).toBe(false);
+ expect(isLegacyAdminPath("/")).toBe(false);
+ });
+
+ it("recognizes development /root paths", () => {
+ expect(isDevelopmentAdminPath("/root")).toBe(true);
+ expect(isDevelopmentAdminPath("/root/media")).toBe(true);
+ expect(isDevelopmentAdminPath("/rootx")).toBe(false);
+ });
+
+ it("recognizes internal admin paths", () => {
+ expect(isInternalAdminPath(INTERNAL_ADMIN_PREFIX)).toBe(true);
+ expect(isInternalAdminPath(`${INTERNAL_ADMIN_PREFIX}/smtp`)).toBe(true);
+ expect(isInternalAdminPath("/admin")).toBe(false);
+ });
+});
+
+describe("path translation", () => {
+ it("maps public paths to internal admin paths", () => {
+ expect(toInternalAdminPath("/")).toBe(INTERNAL_ADMIN_PREFIX);
+ expect(toInternalAdminPath("/portfolio")).toBe(`${INTERNAL_ADMIN_PREFIX}/portfolio`);
+ expect(toInternalAdminPath("portfolio")).toBe(`${INTERNAL_ADMIN_PREFIX}/portfolio`);
+ });
+
+ it("strips the dev /root prefix", () => {
+ expect(fromDevelopmentAdminPath("/root")).toBe("/");
+ expect(fromDevelopmentAdminPath("/root/portfolio")).toBe("/portfolio");
+ expect(fromDevelopmentAdminPath("/portfolio")).toBe("/portfolio");
+ });
+});
+
+describe("getAdminAppPath", () => {
+ it("returns bare paths when a dedicated admin host exists", () => {
+ // default env: admin host != site host -> dedicated host branch
+ expect(getAdminAppPath("/")).toBe("/");
+ expect(getAdminAppPath("/smtp")).toBe("/smtp");
+ });
+
+ it("uses the /root dev prefix when no dedicated host and not production", () => {
+ vi.stubEnv("ADMIN_HOST", "example.com");
+ vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
+ vi.stubEnv("NODE_ENV", "development");
+ expect(getAdminAppPath("/")).toBe("/root");
+ expect(getAdminAppPath("/portfolio")).toBe("/root/portfolio");
+ });
+
+ it("returns bare paths in production even without a dedicated host", () => {
+ vi.stubEnv("ADMIN_HOST", "example.com");
+ vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
+ vi.stubEnv("NODE_ENV", "production");
+ expect(getAdminAppPath("/portfolio")).toBe("/portfolio");
+ });
+});
+
+describe("url builders", () => {
+ it("builds admin urls from NEXT_PUBLIC_ADMIN_URL", () => {
+ expect(getAdminBaseUrl()).toBe("https://root.mohfarawati.de");
+ expect(buildAdminUrl("/smtp")).toBe("https://root.mohfarawati.de/smtp");
+ });
+
+ it("trims trailing slashes from configured base urls", () => {
+ vi.stubEnv("NEXT_PUBLIC_ADMIN_URL", "https://admin.example.com/");
+ expect(getAdminBaseUrl()).toBe("https://admin.example.com");
+ });
+
+ it("builds site urls from NEXT_PUBLIC_SITE_URL", () => {
+ expect(getSiteBaseUrl()).toBe("https://mohfarawati.de");
+ expect(buildSiteUrl("/about")).toBe("https://mohfarawati.de/about");
+ expect(buildSiteUrl("/")).toBe("https://mohfarawati.de/");
+ });
+});
diff --git a/tests/unit/architecture.test.ts b/tests/unit/architecture.test.ts
new file mode 100644
index 0000000..aa4bc5b
--- /dev/null
+++ b/tests/unit/architecture.test.ts
@@ -0,0 +1,122 @@
+import { readFileSync, readdirSync, statSync } from "fs";
+import path from "path";
+
+import { describe, expect, it } from "vitest";
+
+const ROOT = process.cwd();
+
+function walk(dir: string, filter: (file: string) => boolean): string[] {
+ const absolute = path.join(ROOT, dir);
+ const results: string[] = [];
+ let entries: string[];
+ try {
+ entries = readdirSync(absolute);
+ } catch {
+ return results;
+ }
+ for (const entry of entries) {
+ if (entry === "node_modules" || entry === ".next") continue;
+ const full = path.join(absolute, entry);
+ const rel = path.relative(ROOT, full);
+ if (statSync(full).isDirectory()) {
+ results.push(...walk(rel, filter));
+ } else if (filter(full)) {
+ results.push(rel);
+ }
+ }
+ return results;
+}
+
+const read = (rel: string) => readFileSync(path.join(ROOT, rel), "utf8");
+const isSource = (file: string) => /\.(ts|tsx)$/.test(file) && !file.endsWith(".d.ts");
+
+const componentFiles = walk("components", isSource);
+const appFiles = walk("app", isSource);
+const libFiles = walk("lib", isSource);
+const actionFiles = [...appFiles].filter((file) => /(^|\/)actions\.tsx?$/.test(file));
+
+describe("architecture: data access boundaries", () => {
+ it("no component imports the Prisma client", () => {
+ const offenders = componentFiles.filter((file) => /["']@\/lib\/prisma["']/.test(read(file)));
+ expect(offenders).toEqual([]);
+ });
+
+ it("no client component imports the Prisma client (defense in depth)", () => {
+ const offenders = [...componentFiles, ...appFiles].filter((file) => {
+ const source = read(file);
+ return /["']use client["']/.test(source) && /lib\/prisma/.test(source);
+ });
+ expect(offenders).toEqual([]);
+ });
+
+ it("code imports admin server actions from the canonical _admin source, never the mirrors", () => {
+ const offenders = [...componentFiles, ...appFiles, ...libFiles].filter((file) =>
+ /from\s+["']@\/app\/(root|admin-internal)\//.test(read(file)),
+ );
+ expect(offenders).toEqual([]);
+ });
+
+ it("lib modules never import from the app layer", () => {
+ const offenders = libFiles.filter((file) => {
+ const source = read(file);
+ return /from\s+["']@\/app\//.test(source) || /from\s+["']\.\.\/app\//.test(source);
+ });
+ expect(offenders).toEqual([]);
+ });
+});
+
+describe("architecture: server actions", () => {
+ it("finds the expected server action files", () => {
+ expect(actionFiles.length).toBeGreaterThan(0);
+ });
+
+ it('every actions file starts with the "use server" directive', () => {
+ for (const file of actionFiles) {
+ const firstMeaningfulLine = read(file)
+ .split("\n")
+ .map((line) => line.trim())
+ .find((line) => line.length > 0);
+ expect(firstMeaningfulLine, file).toMatch(/^["']use server["'];?$/);
+ }
+ });
+
+ it("every admin action file enforces authentication", () => {
+ const adminActionFiles = actionFiles.filter((file) => file.includes(`${path.sep}_admin${path.sep}`));
+ expect(adminActionFiles.length).toBeGreaterThan(0);
+ for (const file of adminActionFiles) {
+ const source = read(file);
+ expect(
+ /ensureAdmin\s*\(/.test(source) ||
+ /requireAdminAuth\s*\(/.test(source) ||
+ /isAdminAuthenticated\s*\(/.test(source),
+ file,
+ ).toBe(true);
+ }
+ });
+});
+
+describe("architecture: admin mirror parity", () => {
+ const canonicalPages = walk("app/_admin", (file) => /page\.tsx$/.test(file));
+
+ it("has admin pages to mirror", () => {
+ expect(canonicalPages.length).toBeGreaterThan(0);
+ });
+
+ for (const mirror of ["root", "admin-internal"]) {
+ it(`mirrors every _admin page under app/${mirror} via a re-export`, () => {
+ const missing: string[] = [];
+ for (const page of canonicalPages) {
+ const mirrored = page.replace(`app${path.sep}_admin${path.sep}`, `app${path.sep}${mirror}${path.sep}`);
+ try {
+ const source = read(mirrored);
+ if (!source.includes("_admin")) {
+ missing.push(`${mirrored} (does not re-export _admin)`);
+ }
+ } catch {
+ missing.push(`${mirrored} (missing)`);
+ }
+ }
+ expect(missing).toEqual([]);
+ });
+ }
+});
diff --git a/tests/unit/form-data.test.ts b/tests/unit/form-data.test.ts
new file mode 100644
index 0000000..558b30d
--- /dev/null
+++ b/tests/unit/form-data.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+
+import { isCheckedFormValue } from "@/lib/form-data";
+
+describe("isCheckedFormValue", () => {
+ it("treats standard checkbox values as checked", () => {
+ expect(isCheckedFormValue("on")).toBe(true);
+ expect(isCheckedFormValue("true")).toBe(true);
+ expect(isCheckedFormValue("1")).toBe(true);
+ });
+
+ it("treats other values as unchecked", () => {
+ expect(isCheckedFormValue("off")).toBe(false);
+ expect(isCheckedFormValue("false")).toBe(false);
+ expect(isCheckedFormValue("0")).toBe(false);
+ expect(isCheckedFormValue("")).toBe(false);
+ expect(isCheckedFormValue(null)).toBe(false);
+ expect(isCheckedFormValue("yes")).toBe(false);
+ });
+});
diff --git a/tests/unit/locale.test.ts b/tests/unit/locale.test.ts
new file mode 100644
index 0000000..3f5f24a
--- /dev/null
+++ b/tests/unit/locale.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ FALLBACK_LOCALE,
+ getDirection,
+ getLocalizedPath,
+ getLocalizedPathWithDefault,
+ isSupportedLocale,
+ resolveLocale,
+ stripLocalePrefix,
+} from "@/lib/locale";
+
+describe("isSupportedLocale", () => {
+ it("accepts the three app locales", () => {
+ expect(isSupportedLocale("de")).toBe(true);
+ expect(isSupportedLocale("en")).toBe(true);
+ expect(isSupportedLocale("ar")).toBe(true);
+ });
+
+ it("rejects everything else", () => {
+ expect(isSupportedLocale("fr")).toBe(false);
+ expect(isSupportedLocale("")).toBe(false);
+ expect(isSupportedLocale(undefined)).toBe(false);
+ expect(isSupportedLocale(null)).toBe(false);
+ });
+});
+
+describe("resolveLocale", () => {
+ it("keeps supported locales", () => {
+ expect(resolveLocale("ar", "de")).toBe("ar");
+ });
+
+ it("falls back for unsupported locales", () => {
+ expect(resolveLocale("fr", "en")).toBe("en");
+ expect(resolveLocale(undefined, FALLBACK_LOCALE)).toBe("de");
+ expect(resolveLocale(null, "ar")).toBe("ar");
+ });
+});
+
+describe("getDirection", () => {
+ it("is rtl for arabic only", () => {
+ expect(getDirection("ar")).toBe("rtl");
+ expect(getDirection("de")).toBe("ltr");
+ expect(getDirection("en")).toBe("ltr");
+ expect(getDirection("fr")).toBe("ltr");
+ });
+});
+
+describe("stripLocalePrefix", () => {
+ it("removes a bare locale prefix", () => {
+ expect(stripLocalePrefix("/de")).toBe("/");
+ expect(stripLocalePrefix("/ar")).toBe("/");
+ });
+
+ it("removes a nested locale prefix", () => {
+ expect(stripLocalePrefix("/en/about")).toBe("/about");
+ expect(stripLocalePrefix("/ar/portfolio/x")).toBe("/portfolio/x");
+ });
+
+ it("returns unprefixed paths unchanged", () => {
+ expect(stripLocalePrefix("/about")).toBe("/about");
+ expect(stripLocalePrefix("/")).toBe("/");
+ expect(stripLocalePrefix("")).toBe("/");
+ });
+
+ it("does not strip lookalike segments", () => {
+ expect(stripLocalePrefix("/design")).toBe("/design");
+ });
+});
+
+describe("getLocalizedPathWithDefault", () => {
+ it("keeps the default locale on the bare domain", () => {
+ expect(getLocalizedPathWithDefault("ar", "/", "ar")).toBe("/");
+ expect(getLocalizedPathWithDefault("de", "/", "ar")).toBe("/de");
+ expect(getLocalizedPathWithDefault("en", "/", "ar")).toBe("/en");
+ });
+
+ it("builds nested paths against the configured default", () => {
+ expect(getLocalizedPathWithDefault("ar", "/coming-soon", "ar")).toBe("/coming-soon");
+ expect(getLocalizedPathWithDefault("de", "/coming-soon", "ar")).toBe("/de/coming-soon");
+ expect(getLocalizedPathWithDefault("en", "/portfolio", "de")).toBe("/en/portfolio");
+ });
+
+ it("re-bases an already-prefixed path onto the requested locale", () => {
+ expect(getLocalizedPathWithDefault("en", "/ar/contact", "de")).toBe("/en/contact");
+ expect(getLocalizedPathWithDefault("ar", "/de/about", "ar")).toBe("/about");
+ });
+
+ it("normalizes an empty path to root", () => {
+ expect(getLocalizedPathWithDefault("de", "", "de")).toBe("/");
+ expect(getLocalizedPathWithDefault("en", "", "de")).toBe("/en");
+ });
+
+ it("falls back to the default locale for unsupported input", () => {
+ expect(getLocalizedPathWithDefault("fr", "/about", "de")).toBe("/about");
+ });
+
+ it("getLocalizedPath is an alias of getLocalizedPathWithDefault", () => {
+ expect(getLocalizedPath("en", "/about", "de")).toBe(
+ getLocalizedPathWithDefault("en", "/about", "de"),
+ );
+ });
+});
diff --git a/tests/unit/mail.test.ts b/tests/unit/mail.test.ts
new file mode 100644
index 0000000..5094e82
--- /dev/null
+++ b/tests/unit/mail.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { MailSettings } from "@/lib/mail-settings";
+import { createSmtpTransport, sendContactMessage, sendMail, sendTestEmail } from "@/lib/mail";
+
+function settings(overrides: Partial = {}): MailSettings {
+ return {
+ smtp: { host: "smtp.example.com", port: 587, secure: false, username: "mailer", password: "secret", ...overrides.smtp },
+ sender: { email: "hello@example.com", name: "Studio", ...overrides.sender },
+ recipients: { contact: "contact@example.com", test: "test@example.com", ...overrides.recipients },
+ };
+}
+
+function mockTransport() {
+ const sendMailMock = vi.fn().mockResolvedValue({});
+ const createTransport = vi.fn().mockReturnValue({ sendMail: sendMailMock });
+ return { sendMailMock, createTransport };
+}
+
+describe("createSmtpTransport", () => {
+ it("builds the transport with host, port, secure, and auth", () => {
+ const { createTransport } = mockTransport();
+ createSmtpTransport(settings({ smtp: { host: "h", port: 465, secure: true, username: "u", password: "p" } }), createTransport);
+ expect(createTransport).toHaveBeenCalledWith({
+ host: "h",
+ port: 465,
+ secure: true,
+ auth: { user: "u", pass: "p" },
+ });
+ });
+
+ it("requires host, username and password", () => {
+ const { createTransport } = mockTransport();
+ expect(() => createSmtpTransport(settings({ smtp: { host: "", port: 587, secure: false, username: "u", password: "p" } }), createTransport)).toThrow(/host/i);
+ expect(() => createSmtpTransport(settings({ smtp: { host: "h", port: 587, secure: false, username: "", password: "p" } }), createTransport)).toThrow(/username/i);
+ expect(() => createSmtpTransport(settings({ smtp: { host: "h", port: 587, secure: false, username: "u", password: "" } }), createTransport)).toThrow(/password/i);
+ });
+});
+
+describe("sendMail", () => {
+ it("formats the from header with the sender name", async () => {
+ const { sendMailMock, createTransport } = mockTransport();
+ await sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings(), createTransport });
+ expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ from: "Studio ", to: "x@y.z" }));
+ });
+
+ it("omits the display name when sender name is blank", async () => {
+ const { sendMailMock, createTransport } = mockTransport();
+ await sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings({ sender: { email: "hello@example.com", name: "" } }), createTransport });
+ expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ from: "hello@example.com" }));
+ });
+
+ it("requires a from email", async () => {
+ const { createTransport } = mockTransport();
+ await expect(
+ sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings({ sender: { email: "", name: "" } }), createTransport }),
+ ).rejects.toThrow(/from email/i);
+ });
+});
+
+describe("sendContactMessage", () => {
+ it("targets the contact recipient with reply-to and full body", async () => {
+ const { sendMailMock, createTransport } = mockTransport();
+ await sendContactMessage(
+ { locale: "en", name: "Jane", email: "jane@x.z", phone: "123", company: "Acme", message: "Hi there team." },
+ { settings: settings(), createTransport },
+ );
+ const call = sendMailMock.mock.calls[0][0];
+ expect(call).toMatchObject({ to: "contact@example.com", subject: "New contact message", replyTo: "jane@x.z" });
+ expect(call.text).toContain("Name: Jane");
+ expect(call.text).toContain("Phone: 123");
+ expect(call.text).toContain("Company: Acme");
+ expect(call.text).toContain("Hi there team.");
+ });
+
+ it("falls back to the test recipient when contact is empty", async () => {
+ const { sendMailMock, createTransport } = mockTransport();
+ await sendContactMessage(
+ { locale: "de", name: "Jane", email: "jane@x.z", message: "Fallback works fine." },
+ { settings: settings({ recipients: { contact: "", test: "fallback@x.z" } }), createTransport },
+ );
+ expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "fallback@x.z" }));
+ });
+
+ it("renders dashes for missing optional fields", async () => {
+ const { sendMailMock, createTransport } = mockTransport();
+ await sendContactMessage(
+ { locale: "en", name: "Jane", email: "jane@x.z", message: "No phone or company." },
+ { settings: settings(), createTransport },
+ );
+ const call = sendMailMock.mock.calls[0][0];
+ expect(call.text).toContain("Phone: -");
+ expect(call.text).toContain("Company: -");
+ });
+});
+
+describe("sendTestEmail", () => {
+ it("sends to the test recipient", async () => {
+ const { sendMailMock, createTransport } = mockTransport();
+ await sendTestEmail({ settings: settings(), createTransport });
+ expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "test@example.com", subject: "SMTP test email" }));
+ });
+
+ it("falls back to the contact recipient when test is empty", async () => {
+ const { sendMailMock, createTransport } = mockTransport();
+ await sendTestEmail({ settings: settings({ recipients: { contact: "c@x.z", test: "" } }), createTransport });
+ expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "c@x.z" }));
+ });
+
+ it("propagates transport failures", async () => {
+ const createTransport = vi.fn().mockReturnValue({ sendMail: vi.fn().mockRejectedValue(new Error("Auth failed.")) });
+ await expect(sendTestEmail({ settings: settings(), createTransport })).rejects.toThrow("Auth failed.");
+ });
+});
diff --git a/tests/unit/marquee-settings.test.ts b/tests/unit/marquee-settings.test.ts
new file mode 100644
index 0000000..611ca8e
--- /dev/null
+++ b/tests/unit/marquee-settings.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildDefaultMarqueeSettings,
+ parseMarqueeSettingsValue,
+ splitMarqueeRowItems,
+ syncMarqueeSettingsToGermanSource,
+} from "@/lib/marquee-settings";
+
+describe("buildDefaultMarqueeSettings", () => {
+ it("provides all four rows for every locale", () => {
+ const settings = buildDefaultMarqueeSettings();
+ for (const locale of ["ar", "en", "de"] as const) {
+ expect(settings.locales[locale].row1).toContain("Next.js");
+ expect(settings.locales[locale].row2).toContain("TypeScript");
+ expect(settings.locales[locale].row3).toContain("JavaScript");
+ expect(settings.locales[locale].row4).toContain("Frontend Strategy");
+ }
+ });
+});
+
+describe("syncMarqueeSettingsToGermanSource", () => {
+ it("copies the german rows over english and arabic", () => {
+ const base = buildDefaultMarqueeSettings();
+ base.locales.de.row1 = "GERMAN";
+ base.locales.en.row1 = "english";
+ base.locales.ar.row1 = "arabic";
+ const synced = syncMarqueeSettingsToGermanSource(base);
+ expect(synced.locales.de.row1).toBe("GERMAN");
+ expect(synced.locales.en.row1).toBe("GERMAN");
+ expect(synced.locales.ar.row1).toBe("GERMAN");
+ });
+
+ it("returns independent copies (no shared references)", () => {
+ const synced = syncMarqueeSettingsToGermanSource(buildDefaultMarqueeSettings());
+ synced.locales.en.row1 = "changed";
+ expect(synced.locales.de.row1).not.toBe("changed");
+ });
+});
+
+describe("parseMarqueeSettingsValue", () => {
+ it("returns german-synced defaults for empty input", () => {
+ const settings = parseMarqueeSettingsValue(null);
+ expect(settings.locales.en.row1).toBe(settings.locales.de.row1);
+ expect(settings.locales.de.row1).toContain("Next.js");
+ });
+
+ it("returns defaults for invalid json", () => {
+ const settings = parseMarqueeSettingsValue("{not json");
+ expect(settings.locales.de.row1).toContain("Next.js");
+ });
+
+ it("normalizes stored values, trims, and syncs to german", () => {
+ const settings = parseMarqueeSettingsValue(
+ JSON.stringify({
+ locales: {
+ de: { row1: " Custom Row 1 ", row2: "R2", row3: "R3", row4: "R4" },
+ en: { row1: "IGNORED" },
+ },
+ }),
+ );
+ expect(settings.locales.de.row1).toBe("Custom Row 1");
+ // english is overwritten by the german source
+ expect(settings.locales.en.row1).toBe("Custom Row 1");
+ expect(settings.locales.ar.row2).toBe("R2");
+ });
+
+ it("falls back to per-row defaults when a row is blank", () => {
+ const settings = parseMarqueeSettingsValue(
+ JSON.stringify({ locales: { de: { row1: " ", row2: "", row3: "R3", row4: "R4" } } }),
+ );
+ expect(settings.locales.de.row1).toContain("Next.js");
+ expect(settings.locales.de.row3).toBe("R3");
+ });
+});
+
+describe("splitMarqueeRowItems", () => {
+ it("splits on newlines and commas and trims blanks", () => {
+ expect(splitMarqueeRowItems("A\nB, C\n\n , D")).toEqual(["A", "B", "C", "D"]);
+ });
+
+ it("returns an empty array for whitespace-only input", () => {
+ expect(splitMarqueeRowItems(" \n ")).toEqual([]);
+ });
+});
diff --git a/tests/unit/media-service.test.ts b/tests/unit/media-service.test.ts
new file mode 100644
index 0000000..6ab10bd
--- /dev/null
+++ b/tests/unit/media-service.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getKindFromUploadFile,
+ inferMediaKindFromFileName,
+ inferMediaKindFromMimeType,
+} from "@/lib/media-service";
+
+describe("inferMediaKindFromMimeType", () => {
+ it("classifies image mime types as IMAGE", () => {
+ expect(inferMediaKindFromMimeType("image/png")).toBe("IMAGE");
+ expect(inferMediaKindFromMimeType("image/svg+xml")).toBe("IMAGE");
+ expect(inferMediaKindFromMimeType("image/gif")).toBe("IMAGE");
+ });
+
+ it("classifies everything else as DOCUMENT", () => {
+ expect(inferMediaKindFromMimeType("application/pdf")).toBe("DOCUMENT");
+ expect(inferMediaKindFromMimeType(null)).toBe("DOCUMENT");
+ expect(inferMediaKindFromMimeType(undefined)).toBe("DOCUMENT");
+ expect(inferMediaKindFromMimeType("")).toBe("DOCUMENT");
+ });
+});
+
+describe("inferMediaKindFromFileName", () => {
+ it("treats known image extensions as IMAGE (case-insensitive)", () => {
+ for (const name of ["a.gif", "a.jpg", "a.jpeg", "a.PNG", "a.webp", "a.SVG"]) {
+ expect(inferMediaKindFromFileName(name)).toBe("IMAGE");
+ }
+ });
+
+ it("treats other extensions as DOCUMENT", () => {
+ expect(inferMediaKindFromFileName("report.pdf")).toBe("DOCUMENT");
+ expect(inferMediaKindFromFileName("noext")).toBe("DOCUMENT");
+ });
+});
+
+describe("getKindFromUploadFile", () => {
+ it("derives IMAGE from an image mime type", () => {
+ const file = new File([new Uint8Array([1])], "logo.png", { type: "image/png" });
+ expect(getKindFromUploadFile(file)).toBe("IMAGE");
+ });
+
+ it("derives DOCUMENT from a pdf mime type", () => {
+ const file = new File([new Uint8Array([1])], "doc.pdf", { type: "application/pdf" });
+ expect(getKindFromUploadFile(file)).toBe("DOCUMENT");
+ });
+
+ it("falls back to DOCUMENT for unknown mime types", () => {
+ const file = new File([new Uint8Array([1])], "thing.bin", { type: "application/octet-stream" });
+ expect(getKindFromUploadFile(file)).toBe("DOCUMENT");
+ });
+});
diff --git a/tests/unit/media-storage.test.ts b/tests/unit/media-storage.test.ts
new file mode 100644
index 0000000..541bebe
--- /dev/null
+++ b/tests/unit/media-storage.test.ts
@@ -0,0 +1,85 @@
+import path from "path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ MAX_MEDIA_FILE_SIZE,
+ MEDIA_UPLOAD_ROOT,
+ getExtensionForMimeType,
+ isManagedMediaFilePath,
+ removeManagedMediaFile,
+ resolveMediaUploadPath,
+ sanitizeBaseName,
+} from "@/lib/media-storage";
+
+describe("sanitizeBaseName", () => {
+ it("lowercases, hyphenates, and strips symbols", () => {
+ expect(sanitizeBaseName("Brand Redesign 2026!.svg")).toBe("brand-redesign-2026-svg");
+ });
+
+ it("collapses repeated separators and trims edges", () => {
+ expect(sanitizeBaseName("--Hello___World--")).toBe("hello-world");
+ });
+
+ it("truncates to 60 characters", () => {
+ expect(sanitizeBaseName("a".repeat(100)).length).toBe(60);
+ });
+});
+
+describe("getExtensionForMimeType", () => {
+ it("maps known image and document mime types", () => {
+ expect(getExtensionForMimeType("image/gif")).toBe(".gif");
+ expect(getExtensionForMimeType("image/jpeg")).toBe(".jpg");
+ expect(getExtensionForMimeType("image/png")).toBe(".png");
+ expect(getExtensionForMimeType("image/webp")).toBe(".webp");
+ expect(getExtensionForMimeType("image/svg+xml")).toBe(".svg");
+ expect(getExtensionForMimeType("image/x-icon")).toBe(".ico");
+ expect(getExtensionForMimeType("image/vnd.microsoft.icon")).toBe(".ico");
+ expect(getExtensionForMimeType("application/pdf")).toBe(".pdf");
+ });
+
+ it("returns null for unknown mime types", () => {
+ expect(getExtensionForMimeType("application/zip")).toBeNull();
+ expect(getExtensionForMimeType("")).toBeNull();
+ });
+});
+
+describe("isManagedMediaFilePath", () => {
+ it("accepts managed upload paths only", () => {
+ expect(isManagedMediaFilePath("/uploads/media/covers/x.svg")).toBe(true);
+ expect(isManagedMediaFilePath("https://example.com/x.svg")).toBe(false);
+ expect(isManagedMediaFilePath("../x.svg")).toBe(false);
+ expect(isManagedMediaFilePath("/uploads/other/x.svg")).toBe(false);
+ expect(isManagedMediaFilePath(null)).toBe(false);
+ expect(isManagedMediaFilePath(undefined)).toBe(false);
+ });
+});
+
+describe("resolveMediaUploadPath", () => {
+ it("resolves managed paths inside the upload root", () => {
+ const resolved = resolveMediaUploadPath("/uploads/media/assets/test.svg");
+ expect(resolved.startsWith(MEDIA_UPLOAD_ROOT)).toBe(true);
+ expect(resolved.endsWith(path.join("assets", "test.svg"))).toBe(true);
+ });
+
+ it("throws for unmanaged paths", () => {
+ expect(() => resolveMediaUploadPath("https://example.com/x.svg")).toThrow(/managed/i);
+ });
+
+ it("throws when a traversal attempt escapes the root", () => {
+ expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i);
+ });
+});
+
+describe("removeManagedMediaFile", () => {
+ it("returns false without touching disk for unmanaged paths", async () => {
+ await expect(removeManagedMediaFile("https://example.com/x.svg")).resolves.toBe(false);
+ await expect(removeManagedMediaFile(null)).resolves.toBe(false);
+ });
+});
+
+describe("constants", () => {
+ it("caps media uploads at 5 MB", () => {
+ expect(MAX_MEDIA_FILE_SIZE).toBe(5 * 1024 * 1024);
+ });
+});
diff --git a/tests/unit/media-validation.test.ts b/tests/unit/media-validation.test.ts
new file mode 100644
index 0000000..032bfe0
--- /dev/null
+++ b/tests/unit/media-validation.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+
+import { mediaFieldInputSchema } from "@/lib/media-validation";
+
+const base = { assetId: "", url: "", label: "", kind: "IMAGE" as const };
+
+describe("mediaFieldInputSchema", () => {
+ it("accepts a valid library selection", () => {
+ const parsed = mediaFieldInputSchema.parse({ ...base, mode: "library", assetId: "asset_1" });
+ expect(parsed.mode).toBe("library");
+ expect(parsed.assetId).toBe("asset_1");
+ });
+
+ it("requires an assetId in library mode", () => {
+ expect(() => mediaFieldInputSchema.parse({ ...base, mode: "library" })).toThrow(/media asset/i);
+ });
+
+ it("accepts a valid external url", () => {
+ const parsed = mediaFieldInputSchema.parse({ ...base, mode: "external", url: "https://cdn/x.png" });
+ expect(parsed.url).toBe("https://cdn/x.png");
+ });
+
+ it("accepts a root-relative external url", () => {
+ const parsed = mediaFieldInputSchema.parse({ ...base, mode: "external", url: "/uploads/media/x.png" });
+ expect(parsed.url).toBe("/uploads/media/x.png");
+ });
+
+ it("requires a url in external mode", () => {
+ expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
+ });
+
+ it("rejects malformed urls", () => {
+ expect(() =>
+ mediaFieldInputSchema.parse({ ...base, mode: "external", url: "not-a-url" }),
+ ).toThrow(/absolute URL or start with/i);
+ });
+
+ it("accepts upload mode without asset or url", () => {
+ const parsed = mediaFieldInputSchema.parse({ ...base, mode: "upload" });
+ expect(parsed.mode).toBe("upload");
+ });
+
+ it("rejects an unknown mode", () => {
+ expect(() => mediaFieldInputSchema.parse({ ...base, mode: "sideload" })).toThrow();
+ });
+
+ it("rejects an invalid media kind", () => {
+ expect(() =>
+ mediaFieldInputSchema.parse({ ...base, mode: "upload", kind: "VIDEO" }),
+ ).toThrow();
+ });
+
+ it("trims text fields and defaults optionals to empty strings", () => {
+ const parsed = mediaFieldInputSchema.parse({
+ mode: "library",
+ assetId: " asset_9 ",
+ label: " Logo ",
+ kind: "IMAGE",
+ });
+ expect(parsed.assetId).toBe("asset_9");
+ expect(parsed.label).toBe("Logo");
+ expect(parsed.url).toBe("");
+ });
+});
diff --git a/tests/unit/metadata.test.ts b/tests/unit/metadata.test.ts
new file mode 100644
index 0000000..858cfe1
--- /dev/null
+++ b/tests/unit/metadata.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "vitest";
+
+import { buildDefaultSiteSettings } from "@/lib/site-settings";
+import {
+ applyTitleTemplateFn,
+ buildAppMetadataFromConfig,
+ buildLocaleAlternates,
+ buildLocalizedMetadataFromConfig,
+} from "@/lib/metadata";
+
+const noBindings = {
+ siteLogoLight: null,
+ siteLogoDark: null,
+ favicon: null,
+ defaultOgImage: null,
+};
+
+describe("applyTitleTemplateFn", () => {
+ it("substitutes page title and site name", () => {
+ expect(applyTitleTemplateFn("About", "{pageTitle} | {siteName}", "Studio")).toBe("About | Studio");
+ });
+
+ it("replaces every site-name token but only the first page-title token", () => {
+ expect(applyTitleTemplateFn("P", "{siteName} {pageTitle} {siteName}", "S")).toBe("S P S");
+ });
+
+ it("falls back to a default template when the token is missing", () => {
+ expect(applyTitleTemplateFn("About", "Just Site", "Studio")).toBe("About | Studio");
+ });
+});
+
+describe("buildLocaleAlternates", () => {
+ it("builds canonical, hreflang, and x-default against the default locale", () => {
+ const alt = buildLocaleAlternates("/about", "ar");
+ expect(alt.canonical).toBe("https://mohfarawati.de/about");
+ expect(alt.languages.ar).toBe("https://mohfarawati.de/about");
+ expect(alt.languages.de).toBe("https://mohfarawati.de/de/about");
+ expect(alt.languages.en).toBe("https://mohfarawati.de/en/about");
+ expect(alt.languages["x-default"]).toBe("https://mohfarawati.de/about");
+ });
+
+ it("shifts prefixes when the default locale changes", () => {
+ const alt = buildLocaleAlternates("/about", "en");
+ expect(alt.canonical).toBe("https://mohfarawati.de/about");
+ expect(alt.languages.en).toBe("https://mohfarawati.de/about");
+ expect(alt.languages.de).toBe("https://mohfarawati.de/de/about");
+ });
+});
+
+describe("buildAppMetadataFromConfig", () => {
+ it("uses summary twitter card and omits og images when unset", () => {
+ const settings = buildDefaultSiteSettings("Studio");
+ const metadata = buildAppMetadataFromConfig(settings, noBindings);
+ expect(metadata.title).toBe("Studio");
+ expect(metadata.twitter).toMatchObject({ card: "summary" });
+ expect(metadata.openGraph?.images).toBeUndefined();
+ expect(metadata.metadataBase?.toString()).toBe("https://mohfarawati.de/");
+ });
+
+ it("uses summary_large_image and a versioned favicon when bindings exist", () => {
+ const settings = buildDefaultSiteSettings("Studio");
+ const metadata = buildAppMetadataFromConfig(settings, {
+ ...noBindings,
+ favicon: { assetId: "f", url: "/uploads/media/site-settings/favicon.svg", version: "v9" },
+ defaultOgImage: { assetId: "og", url: "/uploads/media/site-settings/og.png", version: "v9" },
+ });
+ expect(metadata.twitter).toMatchObject({ card: "summary_large_image" });
+ expect(metadata.icons).toMatchObject({ icon: [{ url: "/favicon.ico?v=v9" }] });
+ });
+});
+
+describe("buildLocalizedMetadataFromConfig", () => {
+ it("applies the title template and localized description by default", () => {
+ const settings = buildDefaultSiteSettings("Studio");
+ settings.locales.en.siteDescription = "English description";
+ const metadata = buildLocalizedMetadataFromConfig({
+ settings,
+ bindings: noBindings,
+ locale: "en",
+ pathname: "/about",
+ title: "About",
+ });
+ expect(metadata.title).toBe("About | Studio");
+ expect(metadata.description).toBe("English description");
+ expect(metadata.openGraph?.locale).toBe("en");
+ });
+
+ it("can skip the title template (homepage)", () => {
+ const settings = buildDefaultSiteSettings("Studio");
+ const metadata = buildLocalizedMetadataFromConfig({
+ settings,
+ bindings: noBindings,
+ locale: "ar",
+ pathname: "/",
+ title: "الرئيسية",
+ applyTitleTemplate: false,
+ });
+ expect(metadata.title).toBe("الرئيسية");
+ });
+
+ it("prefers an explicit description over the locale default", () => {
+ const settings = buildDefaultSiteSettings("Studio");
+ const metadata = buildLocalizedMetadataFromConfig({
+ settings,
+ bindings: noBindings,
+ locale: "de",
+ pathname: "/x",
+ title: "T",
+ description: " Custom desc ",
+ });
+ expect(metadata.description).toBe("Custom desc");
+ });
+});
diff --git a/tests/unit/portfolio-form-progress.test.ts b/tests/unit/portfolio-form-progress.test.ts
new file mode 100644
index 0000000..7d27720
--- /dev/null
+++ b/tests/unit/portfolio-form-progress.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getFirstIncompleteWizardStep,
+ getPortfolioWizardProgress,
+ isPortfolioAssetReady,
+ isPortfolioSectionReady,
+} from "@/lib/portfolio-form-progress";
+
+const titles = { titleAr: "ع", titleEn: "en", titleDe: "de" };
+
+function section(overrides: Record = {}) {
+ return {
+ type: "RICH_TEXT" as const,
+ ...titles,
+ bodyAr: "ب",
+ bodyEn: "body",
+ bodyDe: "koerper",
+ linkUrl: "",
+ mediaAssetId: "",
+ ...overrides,
+ };
+}
+
+describe("isPortfolioSectionReady", () => {
+ it("requires titles in all languages", () => {
+ expect(isPortfolioSectionReady(section({ titleEn: "" }))).toBe(false);
+ });
+
+ it("RICH_TEXT/STATS/DELIVERABLES require body in all languages", () => {
+ for (const type of ["RICH_TEXT", "STATS", "DELIVERABLES"] as const) {
+ expect(isPortfolioSectionReady(section({ type }))).toBe(true);
+ expect(isPortfolioSectionReady(section({ type, bodyDe: "" }))).toBe(false);
+ }
+ });
+
+ it("GALLERY requires a media asset id", () => {
+ expect(isPortfolioSectionReady(section({ type: "GALLERY", mediaAssetId: "asset_1" }))).toBe(true);
+ expect(isPortfolioSectionReady(section({ type: "GALLERY", mediaAssetId: "" }))).toBe(false);
+ });
+
+ it("LINK requires a link url", () => {
+ expect(isPortfolioSectionReady(section({ type: "LINK", linkUrl: "https://x" }))).toBe(true);
+ expect(isPortfolioSectionReady(section({ type: "LINK", linkUrl: "" }))).toBe(false);
+ });
+});
+
+describe("isPortfolioAssetReady", () => {
+ it("requires media asset id and alt text in all languages", () => {
+ expect(isPortfolioAssetReady({ mediaAssetId: "a", altAr: "ع", altEn: "e", altDe: "d" })).toBe(true);
+ expect(isPortfolioAssetReady({ mediaAssetId: "", altAr: "ع", altEn: "e", altDe: "d" })).toBe(false);
+ expect(isPortfolioAssetReady({ mediaAssetId: "a", altAr: "ع", altEn: "", altDe: "d" })).toBe(false);
+ });
+});
+
+describe("getPortfolioWizardProgress", () => {
+ const completeInput = {
+ basics: {
+ categoryId: "cat_1",
+ slug: "case-study",
+ clientName: "Client",
+ projectYear: "2025",
+ sortOrder: "1",
+ viewMode: "GRID" as const,
+ },
+ content: {
+ titleAr: "ع", titleEn: "t", titleDe: "t",
+ serviceLabelAr: "خ", serviceLabelEn: "s", serviceLabelDe: "s",
+ summaryAr: "م", summaryEn: "sum", summaryDe: "zus",
+ },
+ sections: [section()],
+ assets: [{ mediaAssetId: "asset_1", altAr: "ع", altEn: "e", altDe: "d" }],
+ };
+
+ it("marks all steps complete for a fully filled project", () => {
+ const progress = getPortfolioWizardProgress(completeInput);
+ expect(progress.every((step) => step.complete)).toBe(true);
+ expect(getFirstIncompleteWizardStep(progress)).toBeNull();
+ });
+
+ it("rejects an invalid slug", () => {
+ const progress = getPortfolioWizardProgress({
+ ...completeInput,
+ basics: { ...completeInput.basics, slug: "Invalid Slug" },
+ });
+ expect(progress.find((s) => s.key === "basics")?.complete).toBe(false);
+ expect(getFirstIncompleteWizardStep(progress)).toBe("basics");
+ });
+
+ it("enforces year bounds 2000..2100", () => {
+ const before = getPortfolioWizardProgress({
+ ...completeInput,
+ basics: { ...completeInput.basics, projectYear: "1999" },
+ });
+ expect(before.find((s) => s.key === "basics")?.complete).toBe(false);
+ const after = getPortfolioWizardProgress({
+ ...completeInput,
+ basics: { ...completeInput.basics, projectYear: "2101" },
+ });
+ expect(after.find((s) => s.key === "basics")?.complete).toBe(false);
+ });
+
+ it("enforces sortOrder bounds 0..9999", () => {
+ const progress = getPortfolioWizardProgress({
+ ...completeInput,
+ basics: { ...completeInput.basics, sortOrder: "-1" },
+ });
+ expect(progress.find((s) => s.key === "basics")?.complete).toBe(false);
+ });
+
+ it("content is incomplete when a summary locale is missing", () => {
+ const progress = getPortfolioWizardProgress({
+ ...completeInput,
+ content: { ...completeInput.content, summaryEn: "" },
+ });
+ expect(progress.find((s) => s.key === "content")?.complete).toBe(false);
+ expect(getFirstIncompleteWizardStep(progress)).toBe("content");
+ });
+
+ it("sections/assets steps require at least one ready entry", () => {
+ const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
+ expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(false);
+ expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(false);
+ });
+
+ it("summarizes section/asset readiness counts", () => {
+ const progress = getPortfolioWizardProgress({
+ ...completeInput,
+ sections: [section(), section({ titleEn: "" })],
+ });
+ expect(progress.find((s) => s.key === "sections")?.summary).toBe("1/2 sections ready.");
+ expect(progress.find((s) => s.key === "sections")?.complete).toBe(false);
+ });
+});
diff --git a/tests/unit/portfolio-validation.test.ts b/tests/unit/portfolio-validation.test.ts
new file mode 100644
index 0000000..5a09ed1
--- /dev/null
+++ b/tests/unit/portfolio-validation.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ assetInputSchema,
+ categoryInputSchema,
+ projectInputSchema,
+ sectionInputSchema,
+} from "@/lib/portfolio-validation";
+
+function category(overrides: Record = {}) {
+ return {
+ slug: "branding",
+ nameAr: "الهوية", nameEn: "Branding", nameDe: "Branding",
+ descriptionAr: "وصف", descriptionEn: "Description", descriptionDe: "Beschreibung",
+ sortOrder: 1,
+ isActive: true,
+ ...overrides,
+ };
+}
+
+function baseMedia(overrides: Record = {}) {
+ return { mode: "external", assetId: "", url: "https://x/y.png", label: "L", kind: "IMAGE", ...overrides };
+}
+
+function section(overrides: Record = {}) {
+ return {
+ type: "RICH_TEXT",
+ titleAr: "ع", titleEn: "t", titleDe: "t",
+ bodyAr: "ب", bodyEn: "b", bodyDe: "b",
+ imagePath: "",
+ linkUrl: "",
+ sortOrder: 0,
+ ...overrides,
+ };
+}
+
+function project(overrides: Record = {}) {
+ return {
+ categoryId: "cat_1",
+ slug: "case-study",
+ viewMode: "GRID",
+ titleAr: "ع", titleEn: "T", titleDe: "T",
+ summaryAr: "م", summaryEn: "S", summaryDe: "S",
+ clientName: "Client",
+ projectYear: 2025,
+ serviceLabelAr: "خ", serviceLabelEn: "Svc", serviceLabelDe: "Svc",
+ previewUrl: "https://example.com",
+ currentCoverImagePath: "",
+ sortOrder: 1,
+ isFeatured: false,
+ isPublished: true,
+ sections: [],
+ assets: [],
+ ...overrides,
+ };
+}
+
+describe("categoryInputSchema", () => {
+ it("accepts a valid payload", () => {
+ expect(categoryInputSchema.parse(category()).slug).toBe("branding");
+ });
+
+ it("requires lowercase hyphenated slugs", () => {
+ expect(() => categoryInputSchema.parse(category({ slug: "Not Valid" }))).toThrow(/slug/i);
+ expect(() => categoryInputSchema.parse(category({ slug: "-leading" }))).toThrow(/slug/i);
+ expect(categoryInputSchema.parse(category({ slug: "multi-word-slug" })).slug).toBe("multi-word-slug");
+ });
+
+ it("requires all name and description locales", () => {
+ expect(() => categoryInputSchema.parse(category({ nameEn: "" }))).toThrow(/nameEn/i);
+ expect(() => categoryInputSchema.parse(category({ descriptionDe: " " }))).toThrow(/descriptionDe/i);
+ });
+
+ it("coerces sortOrder and enforces its range", () => {
+ expect(categoryInputSchema.parse(category({ sortOrder: "5" })).sortOrder).toBe(5);
+ expect(() => categoryInputSchema.parse(category({ sortOrder: 10000 }))).toThrow();
+ expect(() => categoryInputSchema.parse(category({ sortOrder: -1 }))).toThrow();
+ });
+});
+
+describe("sectionInputSchema", () => {
+ it("accepts a RICH_TEXT section with body", () => {
+ expect(sectionInputSchema.parse(section()).type).toBe("RICH_TEXT");
+ });
+
+ it("requires body for RICH_TEXT, STATS and DELIVERABLES", () => {
+ for (const type of ["RICH_TEXT", "STATS", "DELIVERABLES"]) {
+ expect(() => sectionInputSchema.parse(section({ type, bodyEn: "" }))).toThrow(/body/i);
+ }
+ });
+
+ it("requires an image for GALLERY sections", () => {
+ expect(() =>
+ sectionInputSchema.parse(section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", imagePath: "" })),
+ ).toThrow(/image/i);
+ expect(
+ sectionInputSchema.parse(
+ section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", imagePath: "/uploads/media/x.svg" }),
+ ).type,
+ ).toBe("GALLERY");
+ expect(
+ sectionInputSchema.parse(
+ section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", media: baseMedia({ mode: "library", assetId: "a" }) }),
+ ).type,
+ ).toBe("GALLERY");
+ });
+
+ it("requires a link url for LINK sections", () => {
+ expect(() =>
+ sectionInputSchema.parse(section({ type: "LINK", bodyAr: "", bodyEn: "", bodyDe: "", linkUrl: "" })),
+ ).toThrow(/link/i);
+ expect(
+ sectionInputSchema.parse(section({ type: "LINK", bodyAr: "", bodyEn: "", bodyDe: "", linkUrl: "https://x" })).linkUrl,
+ ).toBe("https://x");
+ });
+
+ it("rejects malformed link urls", () => {
+ expect(() => sectionInputSchema.parse(section({ type: "LINK", linkUrl: "javascript:alert(1)" }))).toThrow(/absolute URL/i);
+ });
+
+ it("requires titles in all languages", () => {
+ expect(() => sectionInputSchema.parse(section({ titleAr: "" }))).toThrow(/titleAr/i);
+ });
+});
+
+describe("assetInputSchema", () => {
+ it("accepts a valid image asset", () => {
+ const parsed = assetInputSchema.parse({
+ kind: "IMAGE",
+ filePath: "/uploads/media/assets/x.svg",
+ fileFieldName: "",
+ media: baseMedia(),
+ altAr: "ع", altEn: "a", altDe: "a",
+ sortOrder: 0,
+ });
+ expect(parsed.kind).toBe("IMAGE");
+ });
+
+ it("only allows the IMAGE kind", () => {
+ expect(() =>
+ assetInputSchema.parse({ kind: "DOCUMENT", altAr: "ع", altEn: "a", altDe: "a", sortOrder: 0 }),
+ ).toThrow();
+ });
+
+ it("requires alt text in all languages", () => {
+ expect(() =>
+ assetInputSchema.parse({ kind: "IMAGE", altAr: "ع", altEn: "", altDe: "a", sortOrder: 0 }),
+ ).toThrow(/altEn/i);
+ });
+
+ it("rejects invalid embedded media urls", () => {
+ expect(() =>
+ assetInputSchema.parse({
+ kind: "IMAGE",
+ media: baseMedia({ url: "not-a-url" }),
+ altAr: "ع", altEn: "a", altDe: "a",
+ sortOrder: 0,
+ }),
+ ).toThrow(/url/i);
+ });
+});
+
+describe("projectInputSchema", () => {
+ it("accepts a fully valid project", () => {
+ expect(projectInputSchema.parse(project()).slug).toBe("case-study");
+ });
+
+ it("defaults viewMode to GRID and accepts known modes", () => {
+ expect(projectInputSchema.parse(project({ viewMode: undefined })).viewMode).toBe("GRID");
+ expect(projectInputSchema.parse(project({ viewMode: "CASE_STUDY" })).viewMode).toBe("CASE_STUDY");
+ expect(() => projectInputSchema.parse(project({ viewMode: "WILD" }))).toThrow();
+ });
+
+ it("rejects invalid slugs", () => {
+ expect(() => projectInputSchema.parse(project({ slug: "Bad Slug" }))).toThrow(/slug/i);
+ });
+
+ it("coerces and bounds projectYear", () => {
+ expect(projectInputSchema.parse(project({ projectYear: "2025" })).projectYear).toBe(2025);
+ expect(() => projectInputSchema.parse(project({ projectYear: 1999 }))).toThrow();
+ expect(() => projectInputSchema.parse(project({ projectYear: 2101 }))).toThrow();
+ });
+
+ it("requires all localized content fields", () => {
+ expect(() => projectInputSchema.parse(project({ summaryDe: "" }))).toThrow(/summaryDe/i);
+ expect(() => projectInputSchema.parse(project({ serviceLabelAr: "" }))).toThrow(/serviceLabelAr/i);
+ expect(() => projectInputSchema.parse(project({ clientName: "" }))).toThrow(/clientName/i);
+ });
+
+ it("allows an empty preview url but rejects a relative one", () => {
+ expect(projectInputSchema.parse(project({ previewUrl: "" })).previewUrl).toBe("");
+ expect(() => projectInputSchema.parse(project({ previewUrl: "/relative" }))).toThrow(/absolute URL/i);
+ });
+
+ it("validates nested sections and assets", () => {
+ expect(() =>
+ projectInputSchema.parse(project({ sections: [section({ titleEn: "" })] })),
+ ).toThrow(/titleEn/i);
+ expect(
+ projectInputSchema.parse(
+ project({ assets: [{ kind: "IMAGE", altAr: "ع", altEn: "a", altDe: "a", sortOrder: 0, media: baseMedia() }] }),
+ ).assets.length,
+ ).toBe(1);
+ });
+});
diff --git a/tests/unit/portfolio.test.ts b/tests/unit/portfolio.test.ts
new file mode 100644
index 0000000..f322f9f
--- /dev/null
+++ b/tests/unit/portfolio.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import { getLocalizedValue, resolvePortfolioProjectViewMode } from "@/lib/portfolio";
+
+describe("resolvePortfolioProjectViewMode", () => {
+ it("keeps supported view modes", () => {
+ expect(resolvePortfolioProjectViewMode("GRID")).toBe("GRID");
+ expect(resolvePortfolioProjectViewMode("STORY")).toBe("STORY");
+ expect(resolvePortfolioProjectViewMode("CASE_STUDY")).toBe("CASE_STUDY");
+ });
+
+ it("falls back to GRID for unknown or missing values", () => {
+ expect(resolvePortfolioProjectViewMode(undefined)).toBe("GRID");
+ expect(resolvePortfolioProjectViewMode(null)).toBe("GRID");
+ expect(resolvePortfolioProjectViewMode("unexpected")).toBe("GRID");
+ });
+});
+
+describe("getLocalizedValue", () => {
+ const content = { ar: "عربي", en: "English", de: "Deutsch" };
+
+ it("returns the direct locale value when present", () => {
+ expect(getLocalizedValue(content, "en")).toBe("English");
+ expect(getLocalizedValue(content, "ar")).toBe("عربي");
+ });
+
+ it("falls back to the provided fallback locale", () => {
+ expect(getLocalizedValue({ ar: "", en: "", de: "Deutsch" }, "en", "de")).toBe("Deutsch");
+ });
+
+ it("falls back to any available value when neither locale is filled", () => {
+ expect(getLocalizedValue({ ar: "عربي", en: "", de: "" }, "en", "de")).toBe("عربي");
+ });
+
+ it("trims whitespace-only values before considering them empty", () => {
+ expect(getLocalizedValue({ ar: " ", en: " ", de: "Deutsch" }, "en", "de")).toBe("Deutsch");
+ });
+
+ it("returns an empty string when everything is blank", () => {
+ expect(getLocalizedValue({ ar: "", en: "", de: "" }, "en")).toBe("");
+ });
+});
diff --git a/tests/unit/site-icons.test.ts b/tests/unit/site-icons.test.ts
new file mode 100644
index 0000000..0c0081f
--- /dev/null
+++ b/tests/unit/site-icons.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+
+import { buildSiteIconResponse, buildSiteIconUrls } from "@/lib/site-icons";
+
+describe("buildSiteIconUrls", () => {
+ it("uses the default version when none is provided", () => {
+ const urls = buildSiteIconUrls({ siteName: "Studio" });
+ expect(urls.version).toBe("default");
+ expect(urls.faviconHref).toBe("/favicon.ico?v=default");
+ expect(urls.appleIconHref).toBe("/apple-icon.png?v=default");
+ expect(urls.manifestHref).toBe("/manifest.webmanifest?v=default");
+ expect(urls.faviconAssetUrl).toBeNull();
+ });
+
+ it("applies a provided favicon version to internal icon hrefs", () => {
+ const urls = buildSiteIconUrls({ siteName: "Studio", faviconVersion: "v1" });
+ expect(urls.faviconHref).toBe("/favicon.ico?v=v1");
+ expect(urls.appleIconHref).toBe("/apple-icon.png?v=v1");
+ });
+
+ it("versions a relative favicon asset url", () => {
+ const urls = buildSiteIconUrls({
+ siteName: "Studio",
+ faviconVersion: "v2",
+ faviconUrl: "/uploads/media/site-settings/favicon.svg",
+ });
+ expect(urls.faviconAssetUrl).toBe("/uploads/media/site-settings/favicon.svg?v=v2");
+ });
+
+ it("versions an absolute favicon asset url", () => {
+ const urls = buildSiteIconUrls({
+ siteName: "Studio",
+ faviconVersion: "v3",
+ faviconUrl: "https://cdn.example.com/favicon.png",
+ });
+ expect(urls.faviconAssetUrl).toBe("https://cdn.example.com/favicon.png?v=v3");
+ });
+
+ it("falls back to a default site name when empty", () => {
+ expect(buildSiteIconUrls({ siteName: " " }).siteName).toBe("Moh");
+ });
+});
+
+describe("buildSiteIconResponse", () => {
+ it("returns a transparent png for a null icon url", async () => {
+ const response = await buildSiteIconResponse(null);
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Content-Type")).toBe("image/png");
+ expect(response.headers.get("Cache-Control")).toBe("no-store, max-age=0");
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ // PNG magic number
+ expect(Array.from(bytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]);
+ });
+
+ it("returns the transparent fallback for unmanaged paths", async () => {
+ const response = await buildSiteIconResponse("https://example.com/external.png");
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Content-Type")).toBe("image/png");
+ });
+});
diff --git a/tests/unit/site-settings.test.ts b/tests/unit/site-settings.test.ts
new file mode 100644
index 0000000..99191fe
--- /dev/null
+++ b/tests/unit/site-settings.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_SITE_PRIMARY_COLOR,
+ buildDefaultSiteSettings,
+ normalizeSiteDefaultLocale,
+ normalizeSitePrimaryColor,
+ parseSiteSettingsValue,
+} from "@/lib/site-settings";
+
+describe("normalizeSiteDefaultLocale", () => {
+ it("keeps the three supported locales", () => {
+ expect(normalizeSiteDefaultLocale("ar")).toBe("ar");
+ expect(normalizeSiteDefaultLocale("en")).toBe("en");
+ expect(normalizeSiteDefaultLocale("de")).toBe("de");
+ });
+
+ it("defaults to de for anything else", () => {
+ expect(normalizeSiteDefaultLocale("fr")).toBe("de");
+ expect(normalizeSiteDefaultLocale(undefined)).toBe("de");
+ expect(normalizeSiteDefaultLocale(123)).toBe("de");
+ });
+});
+
+describe("normalizeSitePrimaryColor", () => {
+ it("accepts and lowercases 6-digit hex", () => {
+ expect(normalizeSitePrimaryColor("#AABBCC")).toBe("#aabbcc");
+ expect(normalizeSitePrimaryColor(" #112233 ")).toBe("#112233");
+ });
+
+ it("rejects invalid colors", () => {
+ expect(normalizeSitePrimaryColor("red")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
+ expect(normalizeSitePrimaryColor("#abc")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
+ expect(normalizeSitePrimaryColor("#12345g")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
+ expect(normalizeSitePrimaryColor(42)).toBe(DEFAULT_SITE_PRIMARY_COLOR);
+ expect(normalizeSitePrimaryColor(null)).toBe(DEFAULT_SITE_PRIMARY_COLOR);
+ });
+});
+
+describe("buildDefaultSiteSettings", () => {
+ it("uses the fallback name across all locales", () => {
+ const settings = buildDefaultSiteSettings("Studio Moh");
+ expect(settings.defaultLocale).toBe("de");
+ expect(settings.brand.primaryColor).toBe(DEFAULT_SITE_PRIMARY_COLOR);
+ expect(settings.locales.ar.siteName).toBe("Studio Moh");
+ expect(settings.locales.en.titleTemplate).toBe("{pageTitle} | {siteName}");
+ expect(settings.locales.de.subhead).toBe("");
+ });
+});
+
+describe("parseSiteSettingsValue", () => {
+ it("returns defaults for empty input", () => {
+ expect(parseSiteSettingsValue(null, "Fallback")).toEqual(buildDefaultSiteSettings("Fallback"));
+ });
+
+ it("returns defaults for invalid json", () => {
+ expect(parseSiteSettingsValue("{bad", "Fallback")).toEqual(buildDefaultSiteSettings("Fallback"));
+ });
+
+ it("merges stored values with safe defaults", () => {
+ const settings = parseSiteSettingsValue(
+ JSON.stringify({
+ defaultLocale: "ar",
+ brand: { primaryColor: "#112233" },
+ locales: {
+ en: { siteName: "Brand EN", titleTemplate: "{pageTitle} - {siteName}", siteDescription: "English" },
+ de: { siteName: "Brand DE", subhead: "Sub" },
+ },
+ }),
+ "Fallback",
+ );
+ expect(settings.defaultLocale).toBe("ar");
+ expect(settings.brand.primaryColor).toBe("#112233");
+ expect(settings.locales.en.siteName).toBe("Brand EN");
+ expect(settings.locales.en.titleTemplate).toBe("{pageTitle} - {siteName}");
+ expect(settings.locales.en.subhead).toBe("");
+ expect(settings.locales.ar.siteName).toBe("Fallback");
+ expect(settings.locales.de.subhead).toBe("Sub");
+ });
+
+ it("ignores title templates that lack the {pageTitle} token", () => {
+ const settings = parseSiteSettingsValue(
+ JSON.stringify({ locales: { en: { siteName: "X", titleTemplate: "no token here" } } }),
+ "Fallback",
+ );
+ expect(settings.locales.en.titleTemplate).toBe("{pageTitle} | {siteName}");
+ });
+
+ it("falls back for invalid default locale and primary color", () => {
+ const settings = parseSiteSettingsValue(
+ JSON.stringify({ defaultLocale: "fr", brand: { primaryColor: "nope" } }),
+ "Fallback",
+ );
+ expect(settings.defaultLocale).toBe("de");
+ expect(settings.brand.primaryColor).toBe(DEFAULT_SITE_PRIMARY_COLOR);
+ });
+
+ it("trims string fields", () => {
+ const settings = parseSiteSettingsValue(
+ JSON.stringify({ locales: { de: { siteName: " Trimmed ", siteDescription: " d " } } }),
+ "Fallback",
+ );
+ expect(settings.locales.de.siteName).toBe("Trimmed");
+ expect(settings.locales.de.siteDescription).toBe("d");
+ });
+});
diff --git a/tests/unit/site-theme.test.ts b/tests/unit/site-theme.test.ts
new file mode 100644
index 0000000..8b504e9
--- /dev/null
+++ b/tests/unit/site-theme.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+
+import { buildSiteThemeStyleText, buildSiteThemeTokens } from "@/lib/site-theme";
+
+const CHANNEL = /^\d+ \d+% \d+%$/;
+
+describe("buildSiteThemeTokens", () => {
+ it("converts pure red to the expected HSL channels", () => {
+ const tokens = buildSiteThemeTokens("#ff0000");
+ expect(tokens.light.primary).toBe("0 100% 50%");
+ // dark primary lightens by 6 and clamps saturation into [40,95]
+ expect(tokens.dark.primary).toBe("0 95% 56%");
+ });
+
+ it("produces zero saturation for a neutral gray", () => {
+ const tokens = buildSiteThemeTokens("#808080");
+ expect(tokens.light.primary.startsWith("0 0%")).toBe(true);
+ });
+
+ it("emits well-formed channel strings for every token", () => {
+ const tokens = buildSiteThemeTokens("#dc5a35");
+ for (const value of [
+ tokens.light.primary,
+ tokens.light.secondary,
+ tokens.dark.primary,
+ tokens.dark.secondary,
+ ]) {
+ expect(value).toMatch(CHANNEL);
+ }
+ });
+
+ it("derives distinct dark and secondary variants", () => {
+ const tokens = buildSiteThemeTokens("#dc5a35");
+ expect(tokens.dark.primary).not.toBe(tokens.light.primary);
+ expect(tokens.light.secondary).not.toBe(tokens.light.primary);
+ });
+
+ it("falls back to the default brand color for invalid input", () => {
+ expect(buildSiteThemeTokens("not-a-color")).toEqual(buildSiteThemeTokens("#dc5a35"));
+ expect(buildSiteThemeTokens("")).toEqual(buildSiteThemeTokens("#dc5a35"));
+ });
+});
+
+describe("buildSiteThemeStyleText", () => {
+ it("emits :root and .dark blocks with the derived channels", () => {
+ const css = buildSiteThemeStyleText("#ff0000");
+ expect(css).toContain(":root {");
+ expect(css).toContain(".dark {");
+ expect(css).toContain("--primary: 0 100% 50%;");
+ expect(css).toContain("--brand-secondary:");
+ expect(css).toContain("--sidebar-ring:");
+ });
+});
diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts
new file mode 100644
index 0000000..04de34e
--- /dev/null
+++ b/tests/unit/utils.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+
+import { cn } from "@/lib/utils";
+
+describe("cn", () => {
+ it("joins truthy class values", () => {
+ expect(cn("a", "b")).toBe("a b");
+ });
+
+ it("ignores falsey values", () => {
+ expect(cn("a", false, null, undefined, "", "b")).toBe("a b");
+ });
+
+ it("supports conditional object syntax", () => {
+ expect(cn("base", { active: true, hidden: false })).toBe("base active");
+ });
+
+ it("merges conflicting tailwind classes, last wins", () => {
+ expect(cn("px-2", "px-4")).toBe("px-4");
+ expect(cn("text-sm text-red-500", "text-lg")).toBe("text-red-500 text-lg");
+ });
+
+ it("flattens arrays", () => {
+ expect(cn(["a", "b"], "c")).toBe("a b c");
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 39c7137..897efc6 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -4,14 +4,50 @@ import { fileURLToPath } from "url";
import { defineConfig } from "vitest/config";
const rootDir = path.dirname(fileURLToPath(new URL(import.meta.url)));
+const alias = { "@": rootDir };
+const esbuild = { jsx: "automatic" as const };
export default defineConfig({
- resolve: {
- alias: {
- "@": rootDir,
- },
- },
+ resolve: { alias },
+ esbuild,
test: {
- environment: "node",
+ projects: [
+ {
+ resolve: { alias },
+ esbuild,
+ test: {
+ name: "unit",
+ environment: "node",
+ include: ["tests/unit/**/*.test.ts", "tests/*.test.ts"],
+ },
+ },
+ {
+ resolve: { alias },
+ esbuild,
+ test: {
+ name: "integration",
+ environment: "node",
+ include: ["tests/integration/**/*.test.{ts,tsx}"],
+ // One shared test database. Files run sequentially (never in parallel)
+ // but each in its own worker, so every file gets a fresh database
+ // connection and truncation between tests never races another file.
+ fileParallelism: false,
+ globalSetup: ["tests/helpers/global-db-setup.ts"],
+ setupFiles: ["tests/helpers/integration-setup.ts"],
+ hookTimeout: 60_000,
+ testTimeout: 30_000,
+ },
+ },
+ {
+ resolve: { alias },
+ esbuild,
+ test: {
+ name: "component",
+ environment: "jsdom",
+ include: ["tests/component/**/*.test.{ts,tsx}"],
+ setupFiles: ["tests/helpers/component-setup.ts"],
+ },
+ },
+ ],
},
});