API and web running with same command
This commit is contained in:
@@ -9,6 +9,8 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This starts both the Vite dev server (frontend) and the API server (default port 3001). To run only the frontend: `npm run dev:web`.
|
||||||
|
|
||||||
Copy `.env.sample` to `.env` and set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` for the Svelte app and (optionally) the API server.
|
Copy `.env.sample` to `.env` and set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` for the Svelte app and (optionally) the API server.
|
||||||
|
|
||||||
## API server (REST API for mobile / other clients)
|
## API server (REST API for mobile / other clients)
|
||||||
@@ -16,8 +18,8 @@ Copy `.env.sample` to `.env` and set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON
|
|||||||
The Express API uses the same Supabase backend. Optional env: `SUPABASE_URL`, `SUPABASE_ANON_KEY` (default to `VITE_*`), `PORT` (default `3001`).
|
The Express API uses the same Supabase backend. Optional env: `SUPABASE_URL`, `SUPABASE_ANON_KEY` (default to `VITE_*`), `PORT` (default `3001`).
|
||||||
|
|
||||||
- **Run**: `npm run dev:server` or `npm run start:api`
|
- **Run**: `npm run dev:server` or `npm run start:api`
|
||||||
- **Auth**: `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/session` (Bearer token for protected routes)
|
- **Auth**: `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/session`, `GET /api/auth/profile` (Bearer token for protected routes; profile includes `display_name`, `email`, `avatar_url`)
|
||||||
- **Decks**: `GET /api/decks/mine`, `GET /api/decks/published`, `GET /api/decks/:id`, `POST /api/decks`, `PATCH /api/decks/:id`, `POST /api/decks/:id/publish`, `POST /api/decks/:id/copy`, `GET /api/decks/:id/update-preview`, `POST /api/decks/:id/apply-update`
|
- **Decks**: `GET /api/decks/mine`, `GET /api/decks/published`, `GET /api/decks/:id`, `POST /api/decks`, `PATCH /api/decks/:id`, `DELETE /api/decks/:id`, `POST /api/decks/:id/publish`, `POST /api/decks/:id/copy`, `GET /api/decks/:id/update-preview`, `POST /api/decks/:id/apply-update`
|
||||||
|
|
||||||
## Running tests
|
## Running tests
|
||||||
|
|
||||||
@@ -27,11 +29,19 @@ The Express API uses the same Supabase backend. Optional env: `SUPABASE_URL`, `S
|
|||||||
- **Single run** (CI-friendly):
|
- **Single run** (CI-friendly):
|
||||||
`npm run test:run`
|
`npm run test:run`
|
||||||
|
|
||||||
- **Coverage report**:
|
- **Coverage report** (HTML in `coverage/`, summary in terminal):
|
||||||
`npm run test:coverage`
|
`npm run test:coverage`
|
||||||
|
|
||||||
|
- **Run a specific test file**:
|
||||||
|
`npm run test:run -- src/lib/api/decks.test.js`
|
||||||
|
|
||||||
|
- **Verify all tests pass**:
|
||||||
|
`npm run test:run` — exit code 0 means all passed.
|
||||||
|
|
||||||
Tests use Vitest and `@testing-library/svelte`. Place test files next to the code they cover (e.g. `Component.test.js` beside `Component.svelte`) or in a `__tests__` directory.
|
Tests use Vitest and `@testing-library/svelte`. Place test files next to the code they cover (e.g. `Component.test.js` beside `Component.svelte`) or in a `__tests__` directory.
|
||||||
|
|
||||||
|
**Integration-style tests** (in `src/lib/api/decks.test.js`) cover deck assignment and visibility: decks in “My decks” are only those owned by the user; community lists only published decks; publish/unpublish toggles visibility; “In My decks” (`user_has_this`) is correct when the user owns the deck or has a copy. Run them with `npm run test:run` or `npm run test:run -- src/lib/api/decks.test.js`.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
## Recommended IDE Setup
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|||||||
Generated
+296
@@ -19,6 +19,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"svelte": "^5.45.2",
|
"svelte": "^5.45.2",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
@@ -1867,6 +1868,39 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/check-error": {
|
"node_modules/check-error": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||||
@@ -1877,6 +1911,90 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -1907,6 +2025,47 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/concurrently": {
|
||||||
|
"version": "9.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
|
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "4.1.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"shell-quote": "1.8.3",
|
||||||
|
"supports-color": "8.1.1",
|
||||||
|
"tree-kill": "1.2.2",
|
||||||
|
"yargs": "17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"conc": "dist/bin/concurrently.js",
|
||||||
|
"concurrently": "dist/bin/concurrently.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/concurrently/node_modules/supports-color": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -2271,6 +2430,16 @@
|
|||||||
"@esbuild/win32-x64": "0.27.3"
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -2494,6 +2663,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -3381,6 +3560,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -3443,6 +3632,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -3578,6 +3777,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
@@ -4023,6 +4235,16 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tree-kill": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tree-kill": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -4499,6 +4721,80 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zimmerframe": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
|
|||||||
+5
-3
@@ -4,7 +4,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "concurrently -n vite,api -c blue,green \"vite\" \"node server/index.js\"",
|
||||||
|
"dev:web": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"svelte": "^5.45.2",
|
"svelte": "^5.45.2",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
@@ -25,9 +27,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.95.3",
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
"svelte-spa-router": "^4.0.1",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.0"
|
"express": "^4.21.0",
|
||||||
|
"svelte-spa-router": "^4.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+38
-2
@@ -137,6 +137,27 @@ app.get('/api/auth/session', requireAuth, (req, res) => {
|
|||||||
res.json({ user: { id: req.userId } });
|
res.json({ user: { id: req.userId } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/profile', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await req.supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, display_name, email, avatar_url')
|
||||||
|
.eq('id', req.userId)
|
||||||
|
.single();
|
||||||
|
if (error && error.code !== 'PGRST116') {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
res.status(404).json({ error: 'Profile not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e?.message ?? 'Failed to load profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/decks/mine', requireAuth, async (req, res) => {
|
app.get('/api/decks/mine', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const decks = await decksApi.fetchMyDecks(req.supabase, req.userId);
|
const decks = await decksApi.fetchMyDecks(req.supabase, req.userId);
|
||||||
@@ -269,14 +290,15 @@ app.patch('/api/decks/:id', requireAuth, express.json(), async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/decks/:id/publish', requireAuth, async (req, res) => {
|
app.post('/api/decks/:id/publish', requireAuth, express.json(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { data: deck } = await req.supabase.from('decks').select('owner_id').eq('id', req.params.id).single();
|
const { data: deck } = await req.supabase.from('decks').select('owner_id').eq('id', req.params.id).single();
|
||||||
if (!deck || deck.owner_id !== req.userId) {
|
if (!deck || deck.owner_id !== req.userId) {
|
||||||
res.status(404).json({ error: 'Deck not found' });
|
res.status(404).json({ error: 'Deck not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await decksApi.togglePublished(req.supabase, req.params.id, true);
|
const published = req.body?.published !== false;
|
||||||
|
await decksApi.togglePublished(req.supabase, req.params.id, published);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e?.message ?? 'Failed to publish' });
|
res.status(500).json({ error: e?.message ?? 'Failed to publish' });
|
||||||
@@ -322,6 +344,20 @@ app.post('/api/decks/:id/apply-update', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete('/api/decks/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data: deck } = await req.supabase.from('decks').select('owner_id').eq('id', req.params.id).single();
|
||||||
|
if (!deck || deck.owner_id !== req.userId) {
|
||||||
|
res.status(404).json({ error: 'Deck not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await decksApi.deleteDeck(req.supabase, req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e?.message ?? 'Failed to delete deck' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`API server listening on http://localhost:${PORT}`);
|
console.log(`API server listening on http://localhost:${PORT}`);
|
||||||
|
|||||||
@@ -63,9 +63,10 @@
|
|||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
userMenuOpen = false;
|
userMenuOpen = false;
|
||||||
auth.logout();
|
profile = null;
|
||||||
|
await auth.logout();
|
||||||
push('/');
|
push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -265,4 +265,116 @@ describe('decks API', () => {
|
|||||||
await expect(decksApi.applySourceUpdate(supabase, 'd1', 'user1')).rejects.toThrow()
|
await expect(decksApi.applySourceUpdate(supabase, 'd1', 'user1')).rejects.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Deck assignment and visibility (integration)', () => {
|
||||||
|
it('fetchMyDecks returns only decks owned by the given user', async () => {
|
||||||
|
const myDecks = [
|
||||||
|
{ id: 'd1', title: 'My Deck', copied_from_deck_id: null, copied_from_version: null, updated_at: '2025-01-01' },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: myDecks, error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 1, error: null })
|
||||||
|
const result = await decksApi.fetchMyDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe('d1')
|
||||||
|
expect(result[0].title).toBe('My Deck')
|
||||||
|
// Supabase mock was queried with owner_id filter; only owned decks in result
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith('decks')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchMyDecks includes both published and unpublished owned decks', async () => {
|
||||||
|
const myDecks = [
|
||||||
|
{ id: 'd1', title: 'Published', published: true, copied_from_deck_id: null, copied_from_version: null, updated_at: '2025-01-01' },
|
||||||
|
{ id: 'd2', title: 'Unpublished', published: false, copied_from_deck_id: null, copied_from_version: null, updated_at: '2025-01-02' },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: myDecks, error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 2, error: null }, { count: 1, error: null })
|
||||||
|
const result = await decksApi.fetchMyDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map((d) => d.title)).toEqual(['Published', 'Unpublished'])
|
||||||
|
expect(result[0].published).toBe(true)
|
||||||
|
expect(result[1].published).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPublishedDecks returns only published decks with user_has_this when user owns deck', async () => {
|
||||||
|
const publishedDecks = [
|
||||||
|
{ id: 'pub1', title: 'Public Deck', owner_id: 'user1', published: true },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: publishedDecks, error: null },
|
||||||
|
{ data: [{ id: 'user1', display_name: 'Me', email: '[email protected]' }], error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 5, error: null })
|
||||||
|
const result = await decksApi.fetchPublishedDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].published).toBe(true)
|
||||||
|
expect(result[0].user_has_this).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPublishedDecks sets user_has_this true when user has a copy (In My decks)', async () => {
|
||||||
|
const publishedDecks = [
|
||||||
|
{ id: 'pub1', title: 'Other User Deck', owner_id: 'otherUser', published: true },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: publishedDecks, error: null },
|
||||||
|
{ data: [{ id: 'otherUser', display_name: 'Other', email: '[email protected]' }], error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [{ copied_from_deck_id: 'pub1' }], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 3, error: null })
|
||||||
|
const result = await decksApi.fetchPublishedDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].owner_id).toBe('otherUser')
|
||||||
|
expect(result[0].user_has_this).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPublishedDecks sets user_has_this false when user does not own and has no copy', async () => {
|
||||||
|
const publishedDecks = [
|
||||||
|
{ id: 'pub1', title: 'Stranger Deck', owner_id: 'stranger', published: true },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: publishedDecks, error: null },
|
||||||
|
{ data: [{ id: 'stranger', display_name: 'Stranger', email: '[email protected]' }], error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 2, error: null })
|
||||||
|
const result = await decksApi.fetchPublishedDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].user_has_this).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPublishedDecks without userId returns user_has_this false for all', async () => {
|
||||||
|
const publishedDecks = [
|
||||||
|
{ id: 'pub1', title: 'Any Deck', owner_id: 'owner1', published: true },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: publishedDecks, error: null },
|
||||||
|
{ data: [{ id: 'owner1', display_name: 'Owner', email: '[email protected]' }], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 1, error: null })
|
||||||
|
const result = await decksApi.fetchPublishedDecks(supabase)
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].user_has_this).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('togglePublished updates published flag so deck can appear or disappear from community', async () => {
|
||||||
|
nextResults.push({ error: null })
|
||||||
|
await decksApi.togglePublished(supabase, 'd1', true)
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith('decks')
|
||||||
|
nextResults.push({ error: null })
|
||||||
|
await decksApi.togglePublished(supabase, 'd1', false)
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith('decks')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user