diff --git a/README.md b/README.md index b68bdd1..eb4e92d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ npm install 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. ## 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`). - **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) -- **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` +- **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`, `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 @@ -27,11 +29,19 @@ The Express API uses the same Supabase backend. Optional env: `SUPABASE_URL`, `S - **Single run** (CI-friendly): `npm run test:run` -- **Coverage report**: +- **Coverage report** (HTML in `coverage/`, summary in terminal): `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. +**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 [VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). diff --git a/package-lock.json b/package-lock.json index 4f6db41..b19d13e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", "@vitest/coverage-v8": "^3.2.4", + "concurrently": "^9.2.1", "jsdom": "^27.0.1", "svelte": "^5.45.2", "vite": "^7.3.1", @@ -1867,6 +1868,39 @@ "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -1877,6 +1911,90 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1907,6 +2025,47 @@ "dev": true, "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": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2271,6 +2430,16 @@ "@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": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2494,6 +2663,16 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3381,6 +3560,16 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3443,6 +3632,16 @@ "dev": true, "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": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3578,6 +3777,19 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4023,6 +4235,16 @@ "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": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4499,6 +4721,80 @@ "dev": true, "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": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 47aed56..60a75a3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "concurrently -n vite,api -c blue,green \"vite\" \"node server/index.js\"", + "dev:web": "vite", "build": "vite build", "preview": "vite preview", "test": "vitest", @@ -18,6 +19,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", "@vitest/coverage-v8": "^3.2.4", + "concurrently": "^9.2.1", "jsdom": "^27.0.1", "svelte": "^5.45.2", "vite": "^7.3.1", @@ -25,9 +27,9 @@ }, "dependencies": { "@supabase/supabase-js": "^2.95.3", - "svelte-spa-router": "^4.0.1", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.21.0" + "express": "^4.21.0", + "svelte-spa-router": "^4.0.1" } -} \ No newline at end of file +} diff --git a/server/index.js b/server/index.js index daf9309..fa2f1d0 100644 --- a/server/index.js +++ b/server/index.js @@ -137,6 +137,27 @@ app.get('/api/auth/session', requireAuth, (req, res) => { 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) => { try { 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 { 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.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 }); } catch (e) { 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; app.listen(PORT, () => { console.log(`API server listening on http://localhost:${PORT}`); diff --git a/src/lib/Navbar.svelte b/src/lib/Navbar.svelte index 7a7f275..5377093 100644 --- a/src/lib/Navbar.svelte +++ b/src/lib/Navbar.svelte @@ -63,9 +63,10 @@ submitting = false; } - function handleLogout() { + async function handleLogout() { userMenuOpen = false; - auth.logout(); + profile = null; + await auth.logout(); push('/'); } diff --git a/src/lib/api/decks.test.js b/src/lib/api/decks.test.js index a0919a2..5fa4385 100644 --- a/src/lib/api/decks.test.js +++ b/src/lib/api/decks.test.js @@ -265,4 +265,116 @@ describe('decks API', () => { 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: 'me@test.com' }], 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: 'other@test.com' }], 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: 's@test.com' }], 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: 'o@test.com' }], 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') + }) + }) })