API and web running with same command

master
gitea 2 weeks ago
parent d99b051a07
commit d870b5238a

@ -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).

296
package-lock.json generated

@ -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",

@ -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"
}
}
}

@ -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}`);

@ -63,9 +63,10 @@
submitting = false;
}
function handleLogout() {
async function handleLogout() {
userMenuOpen = false;
auth.logout();
profile = null;
await auth.logout();
push('/');
}

@ -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')
})
})
})

Loading…
Cancel
Save

Powered by TurnKey Linux.