mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
@@ -46,7 +46,18 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test:unit
|
||||
- run: pnpm test:unit:coverage
|
||||
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
run: bash scripts/coverage-summary.sh "Unit Test Coverage" coverage/unit/coverage-summary.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: unit-coverage
|
||||
path: coverage/unit/
|
||||
retention-days: 14
|
||||
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
@@ -80,9 +91,25 @@ jobs:
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
|
||||
- name: Build (with coverage instrumentation)
|
||||
run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: ./data/test-crumbs.db
|
||||
NODE_ENV: test
|
||||
VITE_COVERAGE: 'true'
|
||||
|
||||
- run: pnpm test:e2e
|
||||
env:
|
||||
CI: true
|
||||
VITE_COVERAGE: 'true'
|
||||
|
||||
- name: Merge E2E coverage
|
||||
if: always()
|
||||
run: npx nyc report
|
||||
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
run: bash scripts/coverage-summary.sh "E2E Test Coverage" coverage/e2e/coverage-summary.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -91,7 +118,14 @@ jobs:
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-coverage
|
||||
path: coverage/e2e/
|
||||
retention-days: 14
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
|
||||
@@ -32,6 +32,10 @@ vite.config.ts.timestamp-*
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
# Coverage
|
||||
/coverage
|
||||
.nyc_output
|
||||
|
||||
# Claude Code
|
||||
/.claude/
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"report-dir": "coverage/e2e",
|
||||
"temp-dir": "coverage/e2e/raw",
|
||||
"reporter": ["text", "html", "lcov", "json-summary"],
|
||||
"include": ["src/**"],
|
||||
"exclude": ["node_modules", "tests"]
|
||||
}
|
||||
@@ -98,6 +98,10 @@ tests/
|
||||
unit/ # Vitest unit tests
|
||||
e2e/ # Playwright e2e tests (Gherkin-style)
|
||||
helpers/fixtures.ts # Shared authenticatedPage fixture
|
||||
website/ # Static landing page (crumbs.bretzel.app)
|
||||
index.html # Landing page with feature cards, screenshots, deploy section
|
||||
styles.css # Landing page styles (retro parchment theme)
|
||||
assets/ # Favicon, screenshots
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.PHONY: help dev build preview check test test-unit test-e2e lint \
|
||||
.PHONY: help dev build preview check test test-unit test-e2e \
|
||||
test-unit-coverage test-e2e-coverage lint \
|
||||
db-push db-generate db-migrate db-studio docs-api \
|
||||
install docker-build docker-up docker-down docker-logs clean \
|
||||
release release-patch release-minor release-major
|
||||
@@ -27,6 +28,12 @@ test-unit: ## Run unit tests only
|
||||
test-e2e: ## Run e2e tests only
|
||||
pnpm test:e2e
|
||||
|
||||
test-unit-coverage: ## Run unit tests with coverage
|
||||
pnpm test:unit:coverage
|
||||
|
||||
test-e2e-coverage: ## Run e2e tests with coverage
|
||||
VITE_COVERAGE=true pnpm build && pnpm test:e2e && npx nyc report
|
||||
|
||||
lint: ## Run linter
|
||||
pnpm lint
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.13.1
|
||||
image: ghcr.io/bretzel-app/crumbs:0.17.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
CREATE TABLE `api_keys` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`key_hash` text NOT NULL,
|
||||
`key_prefix` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`last_used_at` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `note_collaborators` (
|
||||
`note_id` text NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`added_by` integer NOT NULL,
|
||||
`added_at` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `note_user_state` (
|
||||
`note_id` text NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`pinned` integer DEFAULT false NOT NULL,
|
||||
`archived` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `user_preferences` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`key` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_attachments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer DEFAULT 0 NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`thumbnail_path` text,
|
||||
`featured` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint
|
||||
DROP TABLE `attachments`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE TABLE `__new_notes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer DEFAULT 0 NOT NULL,
|
||||
`title` text DEFAULT '' NOT NULL,
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`color` text DEFAULT 'default' NOT NULL,
|
||||
`pinned` integer DEFAULT false NOT NULL,
|
||||
`archived` integer DEFAULT false NOT NULL,
|
||||
`trashed` integer DEFAULT false NOT NULL,
|
||||
`trashed_at` integer,
|
||||
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint
|
||||
DROP TABLE `notes`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint
|
||||
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
|
||||
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_sync_log` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer DEFAULT 0 NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`operation` text NOT NULL,
|
||||
`timestamp` integer NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint
|
||||
DROP TABLE `sync_log`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint
|
||||
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_tags` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer DEFAULT 0 NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint
|
||||
DROP TABLE `tags`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1773016788899,
|
||||
"tag": "0001_strange_lord_hawal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1773150888301,
|
||||
"tag": "0002_deep_black_widow",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+7
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.14.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
@@ -19,7 +19,10 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:coverage": "VITE_COVERAGE=true pnpm build && pnpm test:e2e && nyc report",
|
||||
"coverage:merge-e2e": "nyc merge coverage/e2e/raw coverage/e2e/merged.json && nyc report",
|
||||
"test": "pnpm test:unit && pnpm test:e2e",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
@@ -68,13 +71,16 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@ export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
retries: 0,
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
reporter: 'html',
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
use: {
|
||||
@@ -27,7 +28,7 @@ export default defineConfig({
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm build && pnpm preview --port 4173',
|
||||
command: 'pnpm preview --port 4173',
|
||||
port: 4173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
|
||||
Generated
+925
File diff suppressed because it is too large
Load Diff
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Writes a coverage summary table to $GITHUB_STEP_SUMMARY (or stdout if not in CI).
|
||||
# Usage: scripts/coverage-summary.sh <title> <json-summary-path>
|
||||
set -euo pipefail
|
||||
|
||||
TITLE="${1:?Usage: coverage-summary.sh <title> <json-summary-path>}"
|
||||
JSON="${2:?Usage: coverage-summary.sh <title> <json-summary-path>}"
|
||||
|
||||
if [ ! -f "$JSON" ]; then
|
||||
echo "Coverage summary not found: $JSON" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Resolve to absolute path for require()
|
||||
ABS_JSON="$(cd "$(dirname "$JSON")" && pwd)/$(basename "$JSON")"
|
||||
|
||||
# Extract totals with node (available in CI, no jq dependency)
|
||||
read -r stmts branches funcs lines < <(node -e "
|
||||
const s = require('$ABS_JSON').total;
|
||||
console.log(s.statements.pct, s.branches.pct, s.functions.pct, s.lines.pct);
|
||||
")
|
||||
|
||||
TABLE="## $TITLE
|
||||
|
||||
| Metric | Coverage |
|
||||
|--------|----------|
|
||||
| Statements | ${stmts}% |
|
||||
| Branches | ${branches}% |
|
||||
| Functions | ${funcs}% |
|
||||
| Lines | ${lines}% |
|
||||
"
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
echo "$TABLE" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$TABLE"
|
||||
fi
|
||||
+74
@@ -102,3 +102,77 @@ body::before {
|
||||
.tiptap .selectedCell {
|
||||
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Task list styles — shared between TipTap editor and markdown-it preview */
|
||||
|
||||
/* TipTap: ul[data-type="taskList"] | markdown-it: ul.task-list */
|
||||
.tiptap ul[data-type="taskList"],
|
||||
.prose ul.task-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li::marker,
|
||||
.prose ul.task-list li::marker {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* TipTap: checkbox is inside a label */
|
||||
.tiptap ul[data-type="taskList"] li label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Shared checkbox styles */
|
||||
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* TipTap: text content is in a div>p */
|
||||
.tiptap ul[data-type="taskList"] li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li > div p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Checked item styling */
|
||||
.tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Override prose spacing for task lists */
|
||||
.prose ul[data-type="taskList"],
|
||||
.prose ul.task-list {
|
||||
padding-left: 0;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.prose ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
padding-left: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose ul.task-list li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+19
-24
@@ -22,36 +22,31 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// MCP endpoint: authenticate via Bearer token (API key)
|
||||
if (pathname.startsWith('/api/mcp')) {
|
||||
// API routes: authenticate via Bearer token if present
|
||||
if (pathname.startsWith('/api/')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (token) {
|
||||
const { valid, userId } = validateApiKey(token);
|
||||
if (!valid || !userId) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { valid, userId } = validateApiKey(token);
|
||||
if (!valid || !userId) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const user = getUserForApiKey(userId);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const user = getUserForApiKey(userId);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
event.locals.user = user;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
event.locals.user = user;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Check if setup is complete
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
Heading,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
TextQuote,
|
||||
CodeXml,
|
||||
Bold,
|
||||
@@ -222,7 +223,7 @@
|
||||
<div class="relative" data-dropdown="list">
|
||||
<button
|
||||
onclick={() => toggleDropdown('list')}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList'))}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
@@ -247,6 +248,14 @@
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('taskList'))}
|
||||
data-testid="format-task-list"
|
||||
>
|
||||
<ListChecks size={iconSize} />
|
||||
<span>Task list</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -237,24 +237,3 @@
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article :global(.prose li:has(.task-checkbox)) {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
article :global(.prose li:has(.task-checkbox))::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
article :global(.prose li:has(.task-checkbox))::marker {
|
||||
content: none;
|
||||
}
|
||||
|
||||
article :global(.task-checkbox) {
|
||||
margin-top: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
@@ -26,19 +27,20 @@
|
||||
}
|
||||
|
||||
const { note, isNew = false, onClose }: Props = $props();
|
||||
const prefs = getPreferences();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let title = $state(note?.title ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let content = $state(note?.content ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let color = $state<NoteColor>(note?.color ?? 'default');
|
||||
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
||||
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
let editorTick = $state(0);
|
||||
@@ -134,6 +136,21 @@
|
||||
bgStyle = `background-color: ${colors.bg}`;
|
||||
});
|
||||
|
||||
// Track whether mousedown started on the overlay (not inside the editor).
|
||||
// Prevents closing when the user selects text inside the editor and
|
||||
// releases the mouse outside — the click event fires on the overlay,
|
||||
// but we only want to close if the press also started there.
|
||||
let mousedownOnOverlay = false;
|
||||
|
||||
function handleOverlayMousedown(e: MouseEvent) {
|
||||
mousedownOnOverlay = e.target === e.currentTarget;
|
||||
}
|
||||
|
||||
function handleOverlayClick() {
|
||||
if (mousedownOnOverlay) saveAndClose();
|
||||
mousedownOnOverlay = false;
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
@@ -195,7 +212,8 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={saveAndClose}
|
||||
onmousedown={handleOverlayMousedown}
|
||||
onclick={handleOverlayClick}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
|
||||
interface Props {
|
||||
@@ -38,6 +40,8 @@
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, users } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
const STRANGER_ID = 3;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function shareNote(noteId: string) {
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('canAccessNote', () => {
|
||||
it('should allow owner to access their note', () => {
|
||||
seedNote('n1');
|
||||
const result = canAccessNote(db, 'n1', OWNER_ID);
|
||||
expect(result).toEqual({ canAccess: true, isOwner: true });
|
||||
});
|
||||
|
||||
it('should allow collaborator to access shared note', () => {
|
||||
seedNote('n1');
|
||||
shareNote('n1');
|
||||
const result = canAccessNote(db, 'n1', COLLAB_ID);
|
||||
expect(result).toEqual({ canAccess: true, isOwner: false });
|
||||
});
|
||||
|
||||
it('should deny non-collaborator access', () => {
|
||||
seedNote('n1');
|
||||
const result = canAccessNote(db, 'n1', STRANGER_ID);
|
||||
expect(result).toEqual({ canAccess: false, isOwner: false });
|
||||
});
|
||||
|
||||
it('should return false for non-existent note', () => {
|
||||
const result = canAccessNote(db, 'nonexistent', OWNER_ID);
|
||||
expect(result).toEqual({ canAccess: false, isOwner: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireNoteAccess', () => {
|
||||
it('should throw 404 for unauthorized user', () => {
|
||||
seedNote('n1');
|
||||
expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow();
|
||||
});
|
||||
|
||||
it('should return isOwner for authorized owner', () => {
|
||||
seedNote('n1');
|
||||
expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireNoteOwnership', () => {
|
||||
it('should throw 404 for collaborator', () => {
|
||||
seedNote('n1');
|
||||
shareNote('n1');
|
||||
expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for owner', () => {
|
||||
seedNote('n1');
|
||||
expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('should throw 401 when no user in locals', () => {
|
||||
expect(() => getUserId({ locals: {} as App.Locals })).toThrow();
|
||||
});
|
||||
|
||||
it('should return user id from locals', () => {
|
||||
const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals });
|
||||
expect(id).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteCollaborators } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
@@ -21,7 +21,7 @@ export function requireAdmin(event: EventWithLocals) {
|
||||
/**
|
||||
* Check if a user can access a note (as owner or collaborator).
|
||||
*/
|
||||
export function canAccessNote(noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
|
||||
export function canAccessNote(db: Db, noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!note) return { canAccess: false, isOwner: false };
|
||||
if (note.userId === userId) return { canAccess: true, isOwner: true };
|
||||
@@ -37,8 +37,8 @@ export function canAccessNote(noteId: string, userId: number): { canAccess: bool
|
||||
/**
|
||||
* Require that a user can access a note. Throws 404 if no access.
|
||||
*/
|
||||
export function requireNoteAccess(noteId: string, userId: number): { isOwner: boolean } {
|
||||
const { canAccess, isOwner } = canAccessNote(noteId, userId);
|
||||
export function requireNoteAccess(db: Db, noteId: string, userId: number): { isOwner: boolean } {
|
||||
const { canAccess, isOwner } = canAccessNote(db, noteId, userId);
|
||||
if (!canAccess) throw error(404, 'Not found');
|
||||
return { isOwner };
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function requireNoteAccess(noteId: string, userId: number): { isOwner: bo
|
||||
/**
|
||||
* Require that a user is the owner of a note. Throws 404 if not owner.
|
||||
*/
|
||||
export function requireNoteOwnership(noteId: string, userId: number): void {
|
||||
export function requireNoteOwnership(db: Db, noteId: string, userId: number): void {
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!note || note.userId !== userId) throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync } from 'fs';
|
||||
import { writeFile, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { attachments } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
@@ -16,6 +16,7 @@ if (!existsSync(ATTACHMENTS_DIR)) {
|
||||
}
|
||||
|
||||
export async function saveAttachment(
|
||||
db: Db,
|
||||
noteId: string,
|
||||
file: File,
|
||||
userId: number,
|
||||
@@ -59,7 +60,7 @@ export async function saveAttachment(
|
||||
* Batch fetch attachments for multiple notes in a single query.
|
||||
* Returns a Map of noteId → Attachment[].
|
||||
*/
|
||||
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
|
||||
export function fetchAttachmentsForNotes(db: Db, noteIds: string[]): Map<string, Attachment[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
@@ -80,7 +81,7 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachm
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string, noteId: string) {
|
||||
export async function getAttachment(db: Db, id: string, noteId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(attachments)
|
||||
@@ -88,11 +89,11 @@ export async function getAttachment(id: string, noteId: string) {
|
||||
.get();
|
||||
}
|
||||
|
||||
export async function getAttachmentsByNote(noteId: string) {
|
||||
export async function getAttachmentsByNote(db: Db, noteId: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||
}
|
||||
|
||||
export async function updateAttachment(id: string, noteId: string, data: { featured: boolean }) {
|
||||
export async function updateAttachment(db: Db, id: string, noteId: string, data: { featured: boolean }) {
|
||||
const [updated] = await db
|
||||
.update(attachments)
|
||||
.set({ featured: data.featured })
|
||||
@@ -101,8 +102,8 @@ export async function updateAttachment(id: string, noteId: string, data: { featu
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string, noteId: string) {
|
||||
const attachment = await getAttachment(id, noteId);
|
||||
export async function deleteAttachment(db: Db, id: string, noteId: string) {
|
||||
const attachment = await getAttachment(db, id, noteId);
|
||||
if (attachment) {
|
||||
try {
|
||||
await unlink(attachment.path);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { fetchCollaboratorsForNotes, addCollaborator, removeCollaborator } from './collaborators.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('fetchCollaboratorsForNotes', () => {
|
||||
it('should return Map with user details for shared notes', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
|
||||
const map = fetchCollaboratorsForNotes(db, ['n1']);
|
||||
const collabs = map.get('n1');
|
||||
expect(collabs).toHaveLength(1);
|
||||
expect(collabs![0].userId).toBe(COLLAB_ID);
|
||||
expect(collabs![0].displayName).toBe('Collab');
|
||||
expect(collabs![0].email).toBe('collab@test.com');
|
||||
});
|
||||
|
||||
it('should return empty Map for notes with no collaborators', () => {
|
||||
seedNote('n1');
|
||||
const map = fetchCollaboratorsForNotes(db, ['n1']);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCollaborator', () => {
|
||||
it('should insert collaborator record', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
|
||||
const rows = db.select().from(noteCollaborators).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].userId).toBe(COLLAB_ID);
|
||||
expect(rows[0].addedBy).toBe(OWNER_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCollaborator', () => {
|
||||
it('should clean up noteUserState on removal', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 0 })
|
||||
.run();
|
||||
|
||||
removeCollaborator(db, 'n1', COLLAB_ID);
|
||||
|
||||
const collabs = db.select().from(noteCollaborators).all();
|
||||
expect(collabs).toHaveLength(0);
|
||||
|
||||
const states = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
|
||||
.all();
|
||||
expect(states).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
@@ -7,7 +7,7 @@ import type { Collaborator } from '$lib/types/index.js';
|
||||
* Batch fetch collaborators for multiple notes.
|
||||
* Returns a Map of noteId → Collaborator[].
|
||||
*/
|
||||
export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Collaborator[]> {
|
||||
export function fetchCollaboratorsForNotes(db: Db, noteIds: string[]): Map<string, Collaborator[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
@@ -44,7 +44,7 @@ export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Colla
|
||||
/**
|
||||
* Add a collaborator to a note.
|
||||
*/
|
||||
export function addCollaborator(noteId: string, userId: number, addedBy: number): void {
|
||||
export function addCollaborator(db: Db, noteId: string, userId: number, addedBy: number): void {
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId, userId, addedBy, addedAt: new Date() })
|
||||
.run();
|
||||
@@ -53,7 +53,7 @@ export function addCollaborator(noteId: string, userId: number, addedBy: number)
|
||||
/**
|
||||
* Remove a collaborator from a note. Also cleans up their per-user state.
|
||||
*/
|
||||
export function removeCollaborator(noteId: string, userId: number): void {
|
||||
export function removeCollaborator(db: Db, noteId: string, userId: number): void {
|
||||
db.delete(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
|
||||
.run();
|
||||
@@ -65,7 +65,7 @@ export function removeCollaborator(noteId: string, userId: number): void {
|
||||
/**
|
||||
* Get all collaborator user IDs for a note.
|
||||
*/
|
||||
export function getCollaboratorIds(noteId: string): number[] {
|
||||
export function getCollaboratorIds(db: Db, noteId: string): number[] {
|
||||
return db
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
|
||||
@@ -115,6 +115,14 @@ sqlite.exec(`
|
||||
success INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: detect old schema (no email column on users) and add columns
|
||||
@@ -186,6 +194,7 @@ sqlite.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -247,4 +256,5 @@ if (pwCol && pwCol.notnull) {
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export type Db = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<typeof schema>;
|
||||
export { sqlite };
|
||||
|
||||
@@ -175,3 +175,17 @@ export const loginAttempts = sqliteTable(
|
||||
},
|
||||
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
|
||||
);
|
||||
|
||||
export const userPreferences = sqliteTable(
|
||||
'user_preferences',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
key: text('key').notNull(),
|
||||
value: text('value').notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
|
||||
);
|
||||
|
||||
@@ -94,6 +94,8 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
featured INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -125,6 +127,15 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||
`);
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
reorderNotes
|
||||
} from '../notes-service.js';
|
||||
import { saveAttachment } from '../attachments.js';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const NOTE_COLORS = [
|
||||
'default',
|
||||
@@ -45,7 +46,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
tag: z.string().optional().describe('Filter by tag name')
|
||||
},
|
||||
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
|
||||
let result = listNotes(userId, filter as NoteFilter);
|
||||
let result = listNotes(db, userId, filter as NoteFilter);
|
||||
|
||||
if (tag) {
|
||||
result = result.filter((n) =>
|
||||
@@ -64,7 +65,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Get a single note by ID with its tags and attachments',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const note = getNote(userId, id);
|
||||
const note = getNote(db, userId, id);
|
||||
if (!note) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -88,7 +89,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
checklistMode: z.boolean().optional().describe('Enable checklist mode')
|
||||
},
|
||||
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
|
||||
const note = createNote(userId, { title, content, color, checklistMode });
|
||||
const note = createNote(db, userId, { title, content, color, checklistMode });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
|
||||
};
|
||||
@@ -107,7 +108,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
|
||||
},
|
||||
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
|
||||
const result = updateNote(userId, id, updates);
|
||||
const result = updateNote(db, userId, id, updates);
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -126,7 +127,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Move a note to trash',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { trashed: true });
|
||||
const result = updateNote(db, userId, id, { trashed: true });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -144,7 +145,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Restore a note from trash',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { trashed: false });
|
||||
const result = updateNote(db, userId, id, { trashed: false });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -162,7 +163,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Archive a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { archived: true });
|
||||
const result = updateNote(db, userId, id, { archived: true });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -180,7 +181,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Unarchive a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { archived: false });
|
||||
const result = updateNote(db, userId, id, { archived: false });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -198,7 +199,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Permanently delete a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const existing = getNote(userId, id);
|
||||
const existing = getNote(db, userId, id);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -206,7 +207,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
deleteNote(userId, id);
|
||||
deleteNote(db, userId, id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -223,7 +224,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Search notes by title, content, or tag name',
|
||||
{ query: z.string().describe('Search query') },
|
||||
async ({ query }: { query: string }) => {
|
||||
const results = searchNotes(userId, query);
|
||||
const results = searchNotes(db, userId, query);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
|
||||
};
|
||||
@@ -231,7 +232,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
);
|
||||
|
||||
server.tool('list_tags', 'List all tags', {}, async () => {
|
||||
const allTags = listAllTags(userId);
|
||||
const allTags = listAllTags(db, userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
|
||||
};
|
||||
@@ -245,7 +246,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
|
||||
},
|
||||
async ({ id, pinned }: { id: string; pinned: boolean }) => {
|
||||
const result = updateNote(userId, id, { pinned });
|
||||
const result = updateNote(db, userId, id, { pinned });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -277,7 +278,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
.describe('Array of note IDs with their new sort orders')
|
||||
},
|
||||
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
|
||||
reorderNotes(userId, orders);
|
||||
reorderNotes(db, userId, orders);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
|
||||
};
|
||||
@@ -292,7 +293,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
imageUrl: z.string().url().describe('URL of the image to fetch')
|
||||
},
|
||||
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
|
||||
const existing = getNote(userId, noteId);
|
||||
const existing = getNote(db, userId, noteId);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -320,7 +321,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
const file = new File([buffer], filename, { type: contentType });
|
||||
const attachment = await saveAttachment(noteId, file, userId);
|
||||
const attachment = await saveAttachment(db, noteId, file, userId);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
getNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
searchNotes,
|
||||
reorderNotes
|
||||
} from './notes-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{
|
||||
email: 'owner@test.com',
|
||||
displayName: 'Owner',
|
||||
role: 'admin',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
email: 'collab@test.com',
|
||||
displayName: 'Collab',
|
||||
role: 'user',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
}
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(
|
||||
id: string,
|
||||
overrides: Partial<typeof notes.$inferInsert> = {}
|
||||
) {
|
||||
const now = new Date();
|
||||
db.insert(notes)
|
||||
.values({
|
||||
id,
|
||||
userId: OWNER_ID,
|
||||
title: '',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
function shareNote(noteId: string, userId = COLLAB_ID) {
|
||||
db.insert(noteCollaborators)
|
||||
.values({
|
||||
noteId,
|
||||
userId,
|
||||
addedBy: OWNER_ID,
|
||||
addedAt: new Date()
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('listNotes', () => {
|
||||
it('should return only active owned notes (excludes archived/trashed)', () => {
|
||||
seedNote('active', { title: 'Active' });
|
||||
seedNote('archived', { title: 'Archived', archived: true });
|
||||
seedNote('trashed', { title: 'Trashed', trashed: true });
|
||||
|
||||
const result = listNotes(db, OWNER_ID);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Active');
|
||||
});
|
||||
|
||||
it('should include shared notes with per-user state overlay', () => {
|
||||
seedNote('shared', { title: 'Shared Note' });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 5 })
|
||||
.run();
|
||||
|
||||
const result = listNotes(db, COLLAB_ID);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Shared Note');
|
||||
expect(result[0].pinned).toBeTruthy();
|
||||
expect(result[0].sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it('should sort pinned first, then by updatedAt desc', () => {
|
||||
const old = new Date('2024-01-01');
|
||||
const recent = new Date('2024-01-02');
|
||||
seedNote('old-pinned', { title: 'Old Pinned', pinned: true, updatedAt: old, createdAt: old });
|
||||
seedNote('recent', { title: 'Recent', updatedAt: recent, createdAt: recent });
|
||||
seedNote('old', { title: 'Old', updatedAt: old, createdAt: old });
|
||||
|
||||
const result = listNotes(db, OWNER_ID);
|
||||
expect(result.map((n) => n.title)).toEqual(['Old Pinned', 'Recent', 'Old']);
|
||||
});
|
||||
|
||||
it('should return only archived notes with archived filter', () => {
|
||||
seedNote('active', { title: 'Active' });
|
||||
seedNote('archived', { title: 'Archived', archived: true });
|
||||
seedNote('shared', { title: 'Shared Archived' });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 0 })
|
||||
.run();
|
||||
|
||||
// Owner sees their archived note
|
||||
const ownerResult = listNotes(db, OWNER_ID, 'archived');
|
||||
expect(ownerResult).toHaveLength(1);
|
||||
expect(ownerResult[0].title).toBe('Archived');
|
||||
|
||||
// Collaborator sees shared note they archived
|
||||
const collabResult = listNotes(db, COLLAB_ID, 'archived');
|
||||
expect(collabResult).toHaveLength(1);
|
||||
expect(collabResult[0].title).toBe('Shared Archived');
|
||||
});
|
||||
|
||||
it('should exclude shared notes from trashed filter', () => {
|
||||
seedNote('trashed', { title: 'Trashed', trashed: true });
|
||||
seedNote('shared', { title: 'Shared' });
|
||||
shareNote('shared');
|
||||
|
||||
const result = listNotes(db, COLLAB_ID, 'trashed');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNote', () => {
|
||||
it('should return null for non-existent note', () => {
|
||||
expect(getNote(db, OWNER_ID, 'nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if user has no access', () => {
|
||||
seedNote('private', { title: 'Private' });
|
||||
expect(getNote(db, COLLAB_ID, 'private')).toBeNull();
|
||||
});
|
||||
|
||||
it('should overlay collaborator per-user state', () => {
|
||||
seedNote('shared', { title: 'Shared', pinned: true, sortOrder: 10 });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 5 })
|
||||
.run();
|
||||
|
||||
const note = getNote(db, COLLAB_ID, 'shared');
|
||||
expect(note).not.toBeNull();
|
||||
expect(note!.pinned).toBeFalsy();
|
||||
expect(note!.archived).toBeTruthy();
|
||||
expect(note!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should generate UUID and set version=1', () => {
|
||||
const note = createNote(db, OWNER_ID, { title: 'Test' });
|
||||
expect(note.id).toBeDefined();
|
||||
expect(note.id.length).toBe(36); // UUID format
|
||||
expect(note.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should use provided ID when given', () => {
|
||||
const note = createNote(db, OWNER_ID, { id: 'custom-id', title: 'Test' });
|
||||
expect(note.id).toBe('custom-id');
|
||||
});
|
||||
|
||||
it('should extract tags from content', () => {
|
||||
const note = createNote(db, OWNER_ID, {
|
||||
title: 'My #todo note',
|
||||
content: 'Some #important content'
|
||||
});
|
||||
expect(note.tags).toContain('todo');
|
||||
expect(note.tags).toContain('important');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNote', () => {
|
||||
it('should allow owner to update shared fields directly', () => {
|
||||
seedNote('n1', { title: 'Original' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Updated' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.title).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should allow collaborator to edit content', () => {
|
||||
seedNote('n1', { title: 'Original', content: 'Old' });
|
||||
shareNote('n1');
|
||||
const updated = updateNote(db, COLLAB_ID, 'n1', { content: 'New content' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.content).toBe('New content');
|
||||
});
|
||||
|
||||
it('should route collaborator per-user fields to noteUserState', () => {
|
||||
seedNote('n1', { title: 'Note', pinned: false });
|
||||
shareNote('n1');
|
||||
updateNote(db, COLLAB_ID, 'n1', { pinned: true, sortOrder: 7 });
|
||||
|
||||
// Owner's note pinned should be unchanged
|
||||
const ownerView = getNote(db, OWNER_ID, 'n1');
|
||||
expect(ownerView!.pinned).toBeFalsy();
|
||||
|
||||
// Collaborator sees their per-user state
|
||||
const collabView = getNote(db, COLLAB_ID, 'n1');
|
||||
expect(collabView!.pinned).toBeTruthy();
|
||||
expect(collabView!.sortOrder).toBe(7);
|
||||
});
|
||||
|
||||
it('should return null when collaborator tries to trash', () => {
|
||||
seedNote('n1', { title: 'Note' });
|
||||
shareNote('n1');
|
||||
const result = updateNote(db, COLLAB_ID, 'n1', { trashed: true });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should increment version on update', () => {
|
||||
seedNote('n1', { title: 'V1' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'V2' });
|
||||
expect(updated!.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should extract and sync tags on title/content change', () => {
|
||||
seedNote('n1', { title: 'Original' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||
expect(updated!.tags).toContain('newtag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should allow owner to permanently delete', () => {
|
||||
seedNote('n1', { title: 'Delete me' });
|
||||
const result = deleteNote(db, OWNER_ID, 'n1');
|
||||
expect(result).toBe(true);
|
||||
expect(getNote(db, OWNER_ID, 'n1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false for non-existent or unauthorized', () => {
|
||||
seedNote('n1', { title: 'Private' });
|
||||
expect(deleteNote(db, COLLAB_ID, 'n1')).toBe(false);
|
||||
expect(deleteNote(db, OWNER_ID, 'nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should match title and content', () => {
|
||||
seedNote('n1', { title: 'Meeting notes', content: 'Discuss budget' });
|
||||
seedNote('n2', { title: 'Shopping list', content: 'Buy milk' });
|
||||
|
||||
const results = searchNotes(db, OWNER_ID, 'budget');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n1');
|
||||
});
|
||||
|
||||
it('should include shared notes in results', () => {
|
||||
seedNote('shared', { title: 'Shared doc', content: 'Collaboration' });
|
||||
shareNote('shared');
|
||||
|
||||
const results = searchNotes(db, COLLAB_ID, 'Collaboration');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('shared');
|
||||
});
|
||||
|
||||
it('should return empty array for empty query', () => {
|
||||
seedNote('n1', { title: 'Test' });
|
||||
expect(searchNotes(db, OWNER_ID, '')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderNotes', () => {
|
||||
it('should update owner sortOrder in notes table', () => {
|
||||
seedNote('n1', { title: 'A' });
|
||||
seedNote('n2', { title: 'B' });
|
||||
reorderNotes(db, OWNER_ID, [
|
||||
{ id: 'n1', sortOrder: 2 },
|
||||
{ id: 'n2', sortOrder: 1 }
|
||||
]);
|
||||
|
||||
const n1 = getNote(db, OWNER_ID, 'n1');
|
||||
const n2 = getNote(db, OWNER_ID, 'n2');
|
||||
expect(n1!.sortOrder).toBe(2);
|
||||
expect(n2!.sortOrder).toBe(1);
|
||||
});
|
||||
|
||||
it('should update collaborator sortOrder in noteUserState', () => {
|
||||
seedNote('n1', { title: 'Note', sortOrder: 0 });
|
||||
shareNote('n1');
|
||||
reorderNotes(db, COLLAB_ID, [{ id: 'n1', sortOrder: 5 }]);
|
||||
|
||||
// Owner's sortOrder unchanged
|
||||
const ownerView = getNote(db, OWNER_ID, 'n1');
|
||||
expect(ownerView!.sortOrder).toBe(0);
|
||||
|
||||
// Collaborator sees their sortOrder
|
||||
const collabView = getNote(db, COLLAB_ID, 'n1');
|
||||
expect(collabView!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db as defaultDb } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -9,8 +9,6 @@ import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
const db = defaultDb;
|
||||
|
||||
interface NoteRow {
|
||||
id: string;
|
||||
userId: number;
|
||||
@@ -28,11 +26,11 @@ interface NoteRow {
|
||||
version: number;
|
||||
}
|
||||
|
||||
function hydrateNotes(noteRows: NoteRow[], userId: number) {
|
||||
function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
|
||||
const noteIds = noteRows.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const collabMap = fetchCollaboratorsForNotes(noteIds);
|
||||
const tagMap = fetchTagsForNotes(db, noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
|
||||
const collabMap = fetchCollaboratorsForNotes(db, noteIds);
|
||||
|
||||
return noteRows.map((note) => {
|
||||
const collaborators = collabMap.get(note.id) ?? [];
|
||||
@@ -52,7 +50,7 @@ function hydrateNotes(noteRows: NoteRow[], userId: number) {
|
||||
/**
|
||||
* Get shared notes for a user with per-user state overlay.
|
||||
*/
|
||||
function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||
function getSharedNotes(db: Db, userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||
const rows = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
@@ -90,7 +88,7 @@ function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||
return rows as NoteRow[];
|
||||
}
|
||||
|
||||
export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
export function listNotes(db: Db, userId: number, filter: NoteFilter = 'all') {
|
||||
// Owned notes
|
||||
let conditions;
|
||||
switch (filter) {
|
||||
@@ -114,7 +112,7 @@ export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
// Shared notes (not for trashed filter — collaborators don't see trash)
|
||||
let sharedNotes: NoteRow[] = [];
|
||||
if (filter !== 'trashed') {
|
||||
sharedNotes = getSharedNotes(userId, filter === 'archived' ? 'archived' : 'all');
|
||||
sharedNotes = getSharedNotes(db, userId, filter === 'archived' ? 'archived' : 'all');
|
||||
}
|
||||
|
||||
const combined = [...ownedNotes, ...sharedNotes];
|
||||
@@ -124,11 +122,11 @@ export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
});
|
||||
|
||||
return hydrateNotes(combined, userId);
|
||||
return hydrateNotes(db, combined, userId);
|
||||
}
|
||||
|
||||
export function getNote(userId: number, id: string) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
export function getNote(db: Db, userId: number, id: string) {
|
||||
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||
if (!canAccess) return null;
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
@@ -151,7 +149,7 @@ export function getNote(userId: number, id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const result = hydrateNotes([effectiveNote], userId);
|
||||
const result = hydrateNotes(db, [effectiveNote], userId);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -165,7 +163,7 @@ export interface CreateNoteInput {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export function createNote(userId: number, input: CreateNoteInput) {
|
||||
export function createNote(db: Db, userId: number, input: CreateNoteInput) {
|
||||
const now = new Date();
|
||||
const id = input.id || uuidv4();
|
||||
|
||||
@@ -192,7 +190,7 @@ export function createNote(userId: number, input: CreateNoteInput) {
|
||||
db.transaction((tx) => {
|
||||
tx.insert(notes).values(newNote).run();
|
||||
});
|
||||
syncNoteTags(id, extractedTags, userId);
|
||||
syncNoteTags(db, id, extractedTags, userId);
|
||||
|
||||
return { ...newNote, tags: extractedTags };
|
||||
}
|
||||
@@ -211,8 +209,8 @@ export interface UpdateNoteInput {
|
||||
/** Per-user fields that go to noteUserState for collaborators */
|
||||
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
|
||||
|
||||
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
export function updateNote(db: Db, userId: number, id: string, input: UpdateNoteInput) {
|
||||
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||
if (!canAccess) return null;
|
||||
|
||||
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
@@ -291,14 +289,14 @@ export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
|
||||
if (updated) {
|
||||
const content = `${updated.title} ${updated.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(id, extractedTags, existing.userId);
|
||||
syncNoteTags(db, id, extractedTags, existing.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return getNote(userId, id);
|
||||
return getNote(db, userId, id);
|
||||
}
|
||||
|
||||
export function deleteNote(userId: number, id: string): boolean {
|
||||
export function deleteNote(db: Db, userId: number, id: string): boolean {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
@@ -312,7 +310,7 @@ export function deleteNote(userId: number, id: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function searchNotes(userId: number, query: string) {
|
||||
export function searchNotes(db: Db, userId: number, query: string) {
|
||||
if (!query) return [];
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
@@ -386,17 +384,17 @@ export function searchNotes(userId: number, query: string) {
|
||||
}
|
||||
|
||||
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
|
||||
return hydrateNotes(combined, userId);
|
||||
return hydrateNotes(db, combined, userId);
|
||||
}
|
||||
|
||||
export function listAllTags(userId: number) {
|
||||
export function listAllTags(db: Db, userId: number) {
|
||||
return db.select().from(tags).where(eq(tags.userId, userId)).all();
|
||||
}
|
||||
|
||||
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
|
||||
export function reorderNotes(db: Db, userId: number, orders: { id: string; sortOrder: number }[]) {
|
||||
const now = new Date();
|
||||
for (const { id, sortOrder } of orders) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||
if (!canAccess) continue;
|
||||
|
||||
if (isOwner) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { getPreferences, upsertPreferences } from './preferences.js';
|
||||
|
||||
let db: Db;
|
||||
const USER_ID = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb({ seedUser: true });
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
describe('getPreferences', () => {
|
||||
it('should return empty object for user with no preferences', () => {
|
||||
const prefs = getPreferences(db, USER_ID);
|
||||
expect(prefs).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertPreferences', () => {
|
||||
it('should insert new preferences', () => {
|
||||
const result = upsertPreferences(db, USER_ID, {
|
||||
defaultNoteMode: 'markdown',
|
||||
hideFooter: 'true'
|
||||
});
|
||||
expect(result).toEqual({
|
||||
defaultNoteMode: 'markdown',
|
||||
hideFooter: 'true'
|
||||
});
|
||||
});
|
||||
|
||||
it('should update existing preferences', () => {
|
||||
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
|
||||
const result = upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' });
|
||||
expect(result.defaultNoteMode).toBe('richtext');
|
||||
});
|
||||
|
||||
it('should handle partial updates without affecting other keys', () => {
|
||||
upsertPreferences(db, USER_ID, {
|
||||
defaultNoteMode: 'markdown',
|
||||
hideFooter: 'true'
|
||||
});
|
||||
const result = upsertPreferences(db, USER_ID, { hideFooter: 'false' });
|
||||
expect(result).toEqual({
|
||||
defaultNoteMode: 'markdown',
|
||||
hideFooter: 'false'
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep preferences isolated between users', () => {
|
||||
// Create a second user
|
||||
db.run(
|
||||
/* sql */ `INSERT INTO users (email, display_name, role, auth_provider, created_at) VALUES ('user2@test.com', 'User 2', 'user', 'password', ${Date.now()})`
|
||||
);
|
||||
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
|
||||
upsertPreferences(db, 2, { defaultNoteMode: 'richtext' });
|
||||
|
||||
expect(getPreferences(db, USER_ID).defaultNoteMode).toBe('markdown');
|
||||
expect(getPreferences(db, 2).defaultNoteMode).toBe('richtext');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { userPreferences } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Get all preferences for a user as a key-value record.
|
||||
*/
|
||||
export function getPreferences(db: Db, userId: number): Record<string, string> {
|
||||
const rows = db
|
||||
.select({ key: userPreferences.key, value: userPreferences.value })
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.all();
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
result[row.key] = row.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert multiple preferences for a user.
|
||||
* Uses INSERT OR REPLACE with the unique (user_id, key) index.
|
||||
*/
|
||||
export function upsertPreferences(
|
||||
db: Db,
|
||||
userId: number,
|
||||
prefs: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const now = new Date();
|
||||
for (const [key, value] of Object.entries(prefs)) {
|
||||
db.insert(userPreferences)
|
||||
.values({ userId, key, value, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.key],
|
||||
set: { value, updatedAt: now }
|
||||
})
|
||||
.run();
|
||||
}
|
||||
return getPreferences(db, userId);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { loginAttempts } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { checkRateLimit, recordLoginAttempt, clearOldAttempts } from './rate-limit.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
const IP = '192.168.1.1';
|
||||
const EMAIL = 'test@test.com';
|
||||
|
||||
function addFailedAttempts(count: number, now: Date) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
recordLoginAttempt(db, IP, EMAIL, false, now);
|
||||
}
|
||||
}
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
it('should allow first attempt', () => {
|
||||
const result = checkRateLimit(db, IP, EMAIL);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow up to 4 failed attempts', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(4, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should block after 5 failed attempts within window', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(5, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.retryAfter).toBeDefined();
|
||||
expect(result.retryAfter!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return correct retryAfter value', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(5, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
// Lockout is 15 minutes (900 seconds)
|
||||
expect(result.retryAfter).toBe(900);
|
||||
});
|
||||
|
||||
it('should allow again after lockout expires', () => {
|
||||
const attemptTime = new Date('2024-01-01T00:00:00Z');
|
||||
addFailedAttempts(5, attemptTime);
|
||||
|
||||
// Check at attempt time — should be blocked
|
||||
const blocked = checkRateLimit(db, IP, EMAIL, attemptTime);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
// Check 16 minutes later — should be allowed
|
||||
const later = new Date(attemptTime.getTime() + 16 * 60 * 1000);
|
||||
const allowed = checkRateLimit(db, IP, EMAIL, later);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordLoginAttempt', () => {
|
||||
it('should insert attempt record', () => {
|
||||
recordLoginAttempt(db, IP, EMAIL, false);
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ip).toBe(IP);
|
||||
expect(rows[0].email).toBe(EMAIL);
|
||||
});
|
||||
|
||||
it('should record success/failure flag correctly', () => {
|
||||
recordLoginAttempt(db, IP, EMAIL, true);
|
||||
recordLoginAttempt(db, IP, EMAIL, false);
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows[0].success).toBe(true);
|
||||
expect(rows[1].success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearOldAttempts', () => {
|
||||
it('should remove attempts older than 2x lockout', () => {
|
||||
const old = new Date('2024-01-01T00:00:00Z');
|
||||
const recent = new Date('2024-01-01T01:00:00Z');
|
||||
recordLoginAttempt(db, IP, EMAIL, false, old);
|
||||
recordLoginAttempt(db, IP, EMAIL, false, recent);
|
||||
|
||||
// Clear at a time where old is > 2x lockout (30 min) ago but recent is not
|
||||
const clearTime = new Date(old.getTime() + 31 * 60 * 1000);
|
||||
clearOldAttempts(db, clearTime);
|
||||
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,19 @@
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { loginAttempts } from './db/schema.js';
|
||||
import { and, eq, gte, sql } from 'drizzle-orm';
|
||||
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export function checkRateLimit(
|
||||
db: Db,
|
||||
ip: string,
|
||||
email: string
|
||||
email: string,
|
||||
now?: Date
|
||||
): { allowed: boolean; retryAfter?: number } {
|
||||
// Skip rate limiting in test environment
|
||||
if (process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT_TEST === '1') {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const windowStart = new Date(Date.now() - LOCKOUT_MS);
|
||||
const currentTime = now ?? new Date();
|
||||
const windowStart = new Date(currentTime.getTime() - LOCKOUT_MS);
|
||||
|
||||
// Count failed attempts from this IP in the lockout window
|
||||
const result = db
|
||||
@@ -50,7 +48,7 @@ export function checkRateLimit(
|
||||
|
||||
if (latest) {
|
||||
const retryAfter = Math.ceil(
|
||||
(latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000
|
||||
(latest.timestamp.getTime() + LOCKOUT_MS - currentTime.getTime()) / 1000
|
||||
);
|
||||
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
|
||||
}
|
||||
@@ -60,20 +58,20 @@ export function checkRateLimit(
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export function recordLoginAttempt(ip: string, email: string, success: boolean): void {
|
||||
export function recordLoginAttempt(db: Db, ip: string, email: string, success: boolean, now?: Date): void {
|
||||
db.insert(loginAttempts)
|
||||
.values({
|
||||
ip,
|
||||
email,
|
||||
success,
|
||||
timestamp: new Date()
|
||||
timestamp: now ?? new Date()
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function clearOldAttempts(): void {
|
||||
const cutoff = new Date(Date.now() - LOCKOUT_MS * 2);
|
||||
export function clearOldAttempts(db: Db, now?: Date): void {
|
||||
const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2);
|
||||
db.delete(loginAttempts)
|
||||
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
|
||||
.where(lt(loginAttempts.timestamp, cutoff))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, tags, noteTags, users } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
|
||||
let db: Db;
|
||||
const USER_ID = 1;
|
||||
|
||||
function seedUser() {
|
||||
db.insert(users)
|
||||
.values({ email: 'test@test.com', displayName: 'Test', role: 'admin', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: USER_ID, title: '', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUser();
|
||||
});
|
||||
|
||||
describe('syncNoteTags', () => {
|
||||
it('should create new tags and associations', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['todo', 'urgent'], USER_ID);
|
||||
|
||||
const tagRows = db.select().from(tags).all();
|
||||
expect(tagRows).toHaveLength(2);
|
||||
|
||||
const associations = db.select().from(noteTags).all();
|
||||
expect(associations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove old associations and add new ones', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['old-tag'], USER_ID);
|
||||
syncNoteTags(db, 'n1', ['new-tag'], USER_ID);
|
||||
|
||||
const map = fetchTagsForNotes(db, ['n1']);
|
||||
expect(map.get('n1')).toEqual(['new-tag']);
|
||||
});
|
||||
|
||||
it('should handle empty tag list (removes all)', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['tag1', 'tag2'], USER_ID);
|
||||
syncNoteTags(db, 'n1', [], USER_ID);
|
||||
|
||||
const associations = db.select().from(noteTags).where(eq(noteTags.noteId, 'n1')).all();
|
||||
expect(associations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should deduplicate existing tags', () => {
|
||||
seedNote('n1');
|
||||
seedNote('n2');
|
||||
syncNoteTags(db, 'n1', ['shared-tag'], USER_ID);
|
||||
syncNoteTags(db, 'n2', ['shared-tag'], USER_ID);
|
||||
|
||||
// Should reuse the same tag row
|
||||
const tagRows = db.select().from(tags).all();
|
||||
expect(tagRows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTagsForNotes', () => {
|
||||
it('should return Map of noteId to tag names', () => {
|
||||
seedNote('n1');
|
||||
seedNote('n2');
|
||||
syncNoteTags(db, 'n1', ['alpha', 'beta'], USER_ID);
|
||||
syncNoteTags(db, 'n2', ['gamma'], USER_ID);
|
||||
|
||||
const map = fetchTagsForNotes(db, ['n1', 'n2']);
|
||||
expect(map.get('n1')).toHaveLength(2);
|
||||
expect(map.get('n1')).toContain('alpha');
|
||||
expect(map.get('n1')).toContain('beta');
|
||||
expect(map.get('n2')).toEqual(['gamma']);
|
||||
});
|
||||
|
||||
it('should return empty map for empty noteIds', () => {
|
||||
const map = fetchTagsForNotes(db, []);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteTags, tags } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { eq, and, inArray } from 'drizzle-orm';
|
||||
* Batch fetch tags for multiple notes in a single query.
|
||||
* Returns a Map of noteId → tag names.
|
||||
*/
|
||||
export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
||||
export function fetchTagsForNotes(db: Db, noteIds: string[]): Map<string, string[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
@@ -32,7 +32,7 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
||||
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
|
||||
* Should be called within a transaction for atomicity.
|
||||
*/
|
||||
export function syncNoteTags(noteId: string, tagNames: string[], userId: number) {
|
||||
export function syncNoteTags(db: Db, noteId: string, tagNames: string[], userId: number) {
|
||||
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
|
||||
|
||||
for (const name of tagNames) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
|
||||
import type { UserPreferences } from '$lib/types/preferences.js';
|
||||
|
||||
const STORAGE_KEY = 'crumbs-preferences';
|
||||
|
||||
let preferences = $state<UserPreferences>({ ...DEFAULT_PREFERENCES });
|
||||
|
||||
function loadFromLocalStorage(): UserPreferences {
|
||||
if (!browser) return { ...DEFAULT_PREFERENCES };
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...DEFAULT_PREFERENCES, ...parsed };
|
||||
}
|
||||
} catch {
|
||||
// Corrupted localStorage — use defaults
|
||||
}
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
|
||||
function saveToLocalStorage(prefs: UserPreferences) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// localStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert server key-value record to typed UserPreferences */
|
||||
function fromServerRecord(record: Record<string, string>): Partial<UserPreferences> {
|
||||
const result: Partial<UserPreferences> = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key in DEFAULT_PREFERENCES) {
|
||||
const k = key as keyof UserPreferences;
|
||||
if (BOOLEAN_PREF_KEYS.has(k)) {
|
||||
(result as Record<string, unknown>)[k] = value === 'true';
|
||||
} else {
|
||||
(result as Record<string, unknown>)[k] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Convert UserPreferences to server key-value record */
|
||||
function toServerRecord(prefs: Partial<UserPreferences>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(prefs)) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize preferences — load from localStorage, then sync from server.
|
||||
*/
|
||||
export function initPreferences() {
|
||||
preferences = loadFromLocalStorage();
|
||||
syncPreferencesFromServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reactive preferences object.
|
||||
*/
|
||||
export function getPreferences(): UserPreferences {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single preference. Saves to localStorage and pushes to server.
|
||||
*/
|
||||
export function updatePreference<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
) {
|
||||
preferences = { ...preferences, [key]: value };
|
||||
saveToLocalStorage(preferences);
|
||||
pushToServer({ [key]: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull preferences from server and merge into local state.
|
||||
*/
|
||||
export async function syncPreferencesFromServer() {
|
||||
if (!browser) return;
|
||||
try {
|
||||
const res = await fetch('/api/preferences');
|
||||
if (res.ok) {
|
||||
const serverPrefs: Record<string, string> = await res.json();
|
||||
const parsed = fromServerRecord(serverPrefs);
|
||||
preferences = { ...DEFAULT_PREFERENCES, ...parsed };
|
||||
saveToLocalStorage(preferences);
|
||||
}
|
||||
} catch {
|
||||
// Offline — local state stands
|
||||
}
|
||||
}
|
||||
|
||||
/** Push preference changes to server */
|
||||
async function pushToServer(partial: Partial<UserPreferences>) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
await fetch('/api/preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(toServerRecord(partial))
|
||||
});
|
||||
} catch {
|
||||
// Offline — localStorage already updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
|
||||
|
||||
describe('preferences defaults', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(DEFAULT_PREFERENCES).toEqual({
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open'
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify boolean preference keys', () => {
|
||||
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
|
||||
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
|
||||
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
|
||||
});
|
||||
});
|
||||
+19
-19
@@ -51,7 +51,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].id).toBe('n1');
|
||||
@@ -65,7 +65,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t2.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t2.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
@@ -86,7 +86,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
sortOrder: 5
|
||||
}).run();
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
// Should reflect the collaborator's per-user state, not the owner's
|
||||
@@ -102,7 +102,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
// Owner has pinned=true, sortOrder=10
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, sortOrder: 10 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
// Without noteUserState, collaborator should get defaults (false/0), not owner's values
|
||||
@@ -116,7 +116,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, trashed: true });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
@@ -133,7 +133,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
.run();
|
||||
|
||||
// Collaborator syncs since t1
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].content).toBe('Updated by owner');
|
||||
@@ -157,7 +157,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
// Note shared with COLLAB_ID (owned by OWNER_ID)
|
||||
seedSharedNote('shared-with-collab', { updatedAt: t2, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(2);
|
||||
const ids = changes.map((c) => c.id).sort();
|
||||
@@ -171,12 +171,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Updated by collab', content: 'New content' }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Updated by collab');
|
||||
@@ -189,12 +189,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { pinned: true, sortOrder: 3 }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
// Per-user fields should be in noteUserState, not in notes
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
@@ -213,12 +213,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { trashed: true }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.trashed).toBe(false);
|
||||
@@ -230,12 +230,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
|
||||
// Collaborator's change has a timestamp BEFORE the server's updatedAt
|
||||
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1,
|
||||
data: { title: 'Stale edit' }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Shared Note'); // Unchanged
|
||||
@@ -263,12 +263,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
}).run();
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Hacked' }
|
||||
}], 3, db); // User 3 is not owner or collaborator
|
||||
}], 3); // User 3 is not owner or collaborator
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Private'); // Unchanged
|
||||
@@ -279,12 +279,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Updated' }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
const logs = db.select().from(syncLog).where(eq(syncLog.noteId, 'n1')).all();
|
||||
expect(logs).toHaveLength(1);
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { db as defaultDb } from '$lib/server/db/index.js';
|
||||
import type { Db } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyDb = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<any>;
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
export async function processSyncPush(changes: SyncQueueItem[], userId: number, injectedDb?: AnyDb): Promise<void> {
|
||||
const db = injectedDb ?? defaultDb;
|
||||
export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId: number): Promise<void> {
|
||||
const noteIdsToSyncTags: string[] = [];
|
||||
|
||||
db.transaction((tx) => {
|
||||
@@ -140,7 +136,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number,
|
||||
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (note) {
|
||||
const content = `${note.title} ${note.content}`;
|
||||
syncNoteTags(noteId, extractTags(content), note.userId);
|
||||
syncNoteTags(db, noteId, extractTags(content), note.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,8 +147,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number,
|
||||
* For shared notes, per-user state (pinned, archived, sortOrder) is overlaid
|
||||
* from noteUserState so collaborators get their own view.
|
||||
*/
|
||||
export async function getChangesSince(sinceTimestamp: number, userId: number, injectedDb?: AnyDb) {
|
||||
const db = injectedDb ?? defaultDb;
|
||||
export async function getChangesSince(db: Db, sinceTimestamp: number, userId: number) {
|
||||
const since = new Date(sinceTimestamp);
|
||||
|
||||
// Owned notes
|
||||
|
||||
@@ -97,3 +97,6 @@ export type NoteColor =
|
||||
export type NoteFilter = 'all' | 'archived' | 'trashed';
|
||||
|
||||
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
|
||||
|
||||
export type { UserPreferences } from './preferences.js';
|
||||
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NoteColor } from './index.js';
|
||||
|
||||
export interface UserPreferences {
|
||||
defaultNoteMode: 'richtext' | 'markdown';
|
||||
defaultNoteColor: NoteColor;
|
||||
hideFooter: boolean;
|
||||
sidebarDefaultState: 'open' | 'collapsed';
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open'
|
||||
};
|
||||
|
||||
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
|
||||
'hideFooter'
|
||||
]);
|
||||
@@ -24,6 +24,17 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
const token = new state.Token('html_inline', '', 0);
|
||||
token.content = checkbox;
|
||||
tokens[i].children!.unshift(token);
|
||||
|
||||
// Add task-list class to parent <ul> (once)
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (tokens[j].type === 'bullet_list_open') {
|
||||
const cls = tokens[j].attrGet('class') ?? '';
|
||||
if (!cls.includes('task-list')) {
|
||||
tokens[j].attrJoin('class', 'task-list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,13 +5,21 @@
|
||||
import { loadNotes } from '$lib/stores/notes.js';
|
||||
import { startSync, stopSync } from '$lib/sync/client.js';
|
||||
import { initDb } from '$lib/sync/idb.js';
|
||||
import { initPreferences, getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let sidebarOpen = $state(false);
|
||||
const prefs = $derived(getPreferences());
|
||||
|
||||
onMount(() => {
|
||||
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
|
||||
initPreferences();
|
||||
const prefState = getPreferences();
|
||||
if (prefState.sidebarDefaultState === 'collapsed') {
|
||||
sidebarOpen = false;
|
||||
} else {
|
||||
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
|
||||
}
|
||||
if (data.user) {
|
||||
initDb(data.user.id);
|
||||
}
|
||||
@@ -34,9 +42,11 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
{#if !prefs.hideFooter}
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}" data-testid="app-footer">
|
||||
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { User, Cpu, ShieldCheck } from 'lucide-svelte';
|
||||
import { User, Cpu, ShieldCheck, SlidersHorizontal } from 'lucide-svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let pathname = $derived(page.url.pathname);
|
||||
@@ -19,8 +19,18 @@
|
||||
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
|
||||
<li>
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
href="/settings/preferences"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/preferences' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
data-testid="settings-nav-preferences"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/settings/profile"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/profile' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<User size={16} />
|
||||
Profile
|
||||
@@ -32,7 +42,7 @@
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Cpu size={16} />
|
||||
MCP Server
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
{#if data.user?.role === 'admin'}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
redirect(302, '/settings/preferences');
|
||||
}
|
||||
@@ -122,7 +122,7 @@ ${mcpJsonConfig}`);
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-[var(--text-muted)]">
|
||||
API keys allow external tools (like Claude Code) to access your notes via the MCP protocol.
|
||||
API keys allow external tools to access your notes via the MCP protocol and the REST API.
|
||||
</p>
|
||||
|
||||
<!-- Create Key Form -->
|
||||
@@ -229,9 +229,19 @@ ${mcpJsonConfig}`);
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- REST API Example -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">REST API</h2>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Use your API key to authenticate requests to any <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">/api/*</code> endpoint:
|
||||
</p>
|
||||
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 text-xs text-[var(--text)]">curl -H "Authorization: Bearer {activeApiKey}" \
|
||||
{baseUrl}/api/notes</pre>
|
||||
</section>
|
||||
|
||||
<!-- MCP Config Help -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Client Configuration</h2>
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">MCP Client Configuration</h2>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Add this to your <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">.claude/settings.json</code> (Claude Code):
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import ColorPicker from '$lib/components/ColorPicker.svelte';
|
||||
import { getPreferences, updatePreference } from '$lib/stores/preferences.svelte.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
const prefs = $derived(getPreferences());
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<h2 class="text-xl font-bold text-[var(--text)]">Preferences</h2>
|
||||
|
||||
<!-- Default note mode -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Default note mode</span>
|
||||
<div class="flex gap-1" role="group" aria-label="Default note mode">
|
||||
<button
|
||||
onclick={() => updatePreference('defaultNoteMode', 'richtext')}
|
||||
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'richtext' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
||||
data-testid="pref-mode-richtext"
|
||||
>
|
||||
Rich text
|
||||
</button>
|
||||
<button
|
||||
onclick={() => updatePreference('defaultNoteMode', 'markdown')}
|
||||
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'markdown' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
||||
data-testid="pref-mode-markdown"
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default note color -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Default note color</span>
|
||||
<ColorPicker
|
||||
selected={prefs.defaultNoteColor}
|
||||
onSelect={(color: NoteColor) => updatePreference('defaultNoteColor', color)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Hide footer -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hide-footer"
|
||||
checked={prefs.hideFooter}
|
||||
onchange={() => updatePreference('hideFooter', !prefs.hideFooter)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-hide-footer"
|
||||
/>
|
||||
<label for="hide-footer" class="text-sm text-[var(--text)]">Hide footer</label>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar default state -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sidebar-default"
|
||||
checked={prefs.sidebarDefaultState === 'collapsed'}
|
||||
onchange={() => updatePreference('sidebarDefaultState', prefs.sidebarDefaultState === 'collapsed' ? 'open' : 'collapsed')}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-sidebar-default"
|
||||
/>
|
||||
<label for="sidebar-default" class="text-sm text-[var(--text)]">Start with sidebar collapsed</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { verifyPassword, createSession } from '$lib/server/auth.js';
|
||||
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
|
||||
const ip = getClientAddress();
|
||||
@@ -11,7 +12,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
throw error(400, 'Email and password are required');
|
||||
}
|
||||
|
||||
const rateCheck = checkRateLimit(ip, email);
|
||||
const rateCheck = checkRateLimit(db, ip, email);
|
||||
if (!rateCheck.allowed) {
|
||||
return json(
|
||||
{ error: 'Too many login attempts. Please try again later.', retryAfter: rateCheck.retryAfter },
|
||||
@@ -21,11 +22,11 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
|
||||
const user = await verifyPassword(email, password);
|
||||
if (!user) {
|
||||
recordLoginAttempt(ip, email, false);
|
||||
recordLoginAttempt(db, ip, email, false);
|
||||
throw error(401, 'Invalid email or password');
|
||||
}
|
||||
|
||||
recordLoginAttempt(ip, email, true);
|
||||
recordLoginAttempt(db, ip, email, true);
|
||||
|
||||
const userAgent = request.headers.get('user-agent') || undefined;
|
||||
const token = await createSession(user.id, { userAgent, ip });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { listNotes, createNote } from '$lib/server/notes-service.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
@@ -7,13 +8,13 @@ import type { NoteFilter } from '$lib/types/index.js';
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const filter = (url.searchParams.get('filter') || 'all') as NoteFilter;
|
||||
const result = listNotes(userId, filter);
|
||||
const result = listNotes(db, userId, filter);
|
||||
return json(result);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body = await request.json();
|
||||
const newNote = createNote(userId, body);
|
||||
const newNote = createNote(db, userId, body);
|
||||
return json(newNote, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
||||
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const note = getNote(userId, params.id);
|
||||
const note = getNote(db, userId, params.id);
|
||||
if (!note) throw error(404, 'Note not found');
|
||||
|
||||
return json(note);
|
||||
@@ -14,7 +15,7 @@ export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body = await request.json();
|
||||
const updated = updateNote(userId, params.id, body);
|
||||
const updated = updateNote(db, userId, params.id, body);
|
||||
if (!updated) throw error(404, 'Note not found');
|
||||
|
||||
return json(updated);
|
||||
@@ -22,9 +23,9 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteOwnership(params.id, userId);
|
||||
requireNoteOwnership(db, params.id, userId);
|
||||
|
||||
const deleted = deleteNote(userId, params.id);
|
||||
const deleted = deleteNote(db, userId, params.id);
|
||||
if (!deleted) throw error(404, 'Note not found');
|
||||
|
||||
return json({ success: true });
|
||||
|
||||
@@ -2,16 +2,17 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
|
||||
if (attachmentId) {
|
||||
const attachment = await getAttachment(attachmentId, params.id);
|
||||
const attachment = await getAttachment(db, attachmentId, params.id);
|
||||
if (!attachment) throw error(404, 'Attachment not found');
|
||||
|
||||
const wantThumb = url.searchParams.get('thumb') === '1';
|
||||
@@ -28,13 +29,13 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const list = await getAttachmentsByNote(params.id);
|
||||
const list = await getAttachmentsByNote(db, params.id);
|
||||
return json(list);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
@@ -53,13 +54,13 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
}
|
||||
|
||||
const thumbnail = formData.get('thumbnail') as File | null;
|
||||
const attachment = await saveAttachment(params.id, file, userId, thumbnail);
|
||||
const attachment = await saveAttachment(db, params.id, file, userId, thumbnail);
|
||||
return json(attachment, { status: 201 });
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
@@ -67,7 +68,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
|
||||
const body = await request.json();
|
||||
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
|
||||
|
||||
const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured });
|
||||
const updated = await updateAttachment(db, attachmentId, params.id, { featured: body.featured });
|
||||
if (!updated) throw error(404, 'Attachment not found');
|
||||
|
||||
return json(updated);
|
||||
@@ -75,11 +76,11 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
|
||||
await deleteAttachment(attachmentId, params.id);
|
||||
await deleteAttachment(db, attachmentId, params.id);
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -8,15 +8,15 @@ import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes([params.id]);
|
||||
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
|
||||
return json(collaborators.get(params.id) ?? []);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteOwnership(params.id, userId);
|
||||
requireNoteOwnership(db, params.id, userId);
|
||||
|
||||
const body = await request.json();
|
||||
const targetUserId = body.userId;
|
||||
@@ -46,9 +46,9 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
throw error(409, 'User is already a collaborator');
|
||||
}
|
||||
|
||||
addCollaborator(params.id, targetUserId, userId);
|
||||
addCollaborator(db, params.id, targetUserId, userId);
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes([params.id]);
|
||||
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
|
||||
return json(collaborators.get(params.id) ?? [], { status: 201 });
|
||||
};
|
||||
|
||||
@@ -60,14 +60,14 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
throw error(400, 'userId query parameter is required');
|
||||
}
|
||||
|
||||
const { isOwner } = requireNoteAccess(params.id, userId);
|
||||
const { isOwner } = requireNoteAccess(db, params.id, userId);
|
||||
|
||||
// Owner can remove anyone. Collaborator can only remove self.
|
||||
if (!isOwner && targetUserId !== userId) {
|
||||
throw error(403, 'Only the owner can remove other collaborators');
|
||||
}
|
||||
|
||||
removeCollaborator(params.id, targetUserId);
|
||||
removeCollaborator(db, params.id, targetUserId);
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { reorderNotes } from '$lib/server/notes-service.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
|
||||
@@ -12,6 +13,6 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
return json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
reorderNotes(userId, orders);
|
||||
reorderNotes(db, userId, orders);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { getPreferences, upsertPreferences } from '$lib/server/preferences.js';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const userId = getUserId(event);
|
||||
const prefs = getPreferences(db, userId);
|
||||
return json(prefs);
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body: Record<string, string> = await request.json();
|
||||
const updated = upsertPreferences(db, userId, body);
|
||||
return json(updated);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { searchNotes } from '$lib/server/notes-service.js';
|
||||
|
||||
@@ -11,6 +12,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
const results = searchNotes(userId, query);
|
||||
const results = searchNotes(db, userId, query);
|
||||
return json(results);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
|
||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -13,18 +14,18 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
return json({ error: 'Invalid changes' }, { status: 400 });
|
||||
}
|
||||
|
||||
await processSyncPush(changes, userId);
|
||||
await processSyncPush(db, changes, userId);
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const since = parseInt(url.searchParams.get('since') || '0', 10);
|
||||
const changes = await getChangesSince(since, userId);
|
||||
const changes = await getChangesSince(db, since, userId);
|
||||
|
||||
const noteIds = changes.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const tagMap = fetchTagsForNotes(db, noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
|
||||
const changesWithTags = changes.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
|
||||
async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
@@ -38,54 +38,6 @@ test.describe('Rich text formatting', () => {
|
||||
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
|
||||
});
|
||||
|
||||
test('Scenario: Italic shortcut formats selected text in the editor', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a new note with content "hello world"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await typeViaMarkdown(page, 'hello world');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
|
||||
// When the user selects "world" and applies italic formatting
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleItalic().run()');
|
||||
|
||||
// Then the selected text appears italic in the editor
|
||||
await expect(editor.locator('em')).toHaveText('world');
|
||||
});
|
||||
|
||||
test('Scenario: Formatting toolbar applies bold to selected text', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await typeViaMarkdown(page, 'hello world');
|
||||
|
||||
// When the user selects "world" and clicks the bold button
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).run()');
|
||||
await page.getByTestId('format-bold').click();
|
||||
|
||||
// Then the selected text appears bold
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await expect(editor.locator('strong')).toHaveText('world');
|
||||
});
|
||||
|
||||
test('Scenario: Formatting toolbar is always visible when editing', async ({ authenticatedPage: page }) => {
|
||||
// When the user opens a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// Then the formatting toolbar is visible immediately
|
||||
await expect(page.getByTestId('formatting-toolbar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Inline code shortcut wraps selected text', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a note with content "use npm install"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await typeViaMarkdown(page, 'use npm install');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
|
||||
// When the user selects "npm install" and applies code formatting
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:5,to:16}).toggleCode().run()');
|
||||
|
||||
// Then the selected text appears as inline code
|
||||
await expect(editor.locator('code')).toHaveText('npm install');
|
||||
});
|
||||
|
||||
test('Scenario: Existing note shows rich text in TipTap editor', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with bold markdown content exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const COVERAGE_DIR = join(process.cwd(), 'coverage', 'e2e', 'raw');
|
||||
|
||||
let counter = 0;
|
||||
|
||||
/**
|
||||
* Collect Istanbul coverage data from the browser page.
|
||||
* The vite-plugin-istanbul injects `window.__coverage__` when COVERAGE=true.
|
||||
*/
|
||||
export async function collectCoverage(page: Page, testTitle: string) {
|
||||
if (process.env.VITE_COVERAGE !== 'true') return;
|
||||
|
||||
const coverage = await page.evaluate(() => (window as any).__coverage__);
|
||||
if (!coverage) return;
|
||||
|
||||
mkdirSync(COVERAGE_DIR, { recursive: true });
|
||||
|
||||
const safeName = testTitle.replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
const filename = `coverage-${safeName}-${counter++}-${Date.now()}.json`;
|
||||
writeFileSync(join(COVERAGE_DIR, filename), JSON.stringify(coverage));
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test';
|
||||
import { collectCoverage } from './coverage';
|
||||
|
||||
const TEST_EMAIL = 'admin@test.com';
|
||||
const TEST_PASSWORD = 'testpassword123';
|
||||
const TEST_DISPLAY_NAME = 'Test Admin';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/** Find a specific note card by its title text */
|
||||
export function noteCard(page: Page, title: string): Locator {
|
||||
return page.locator('[data-testid="note-card"]', { hasText: title });
|
||||
@@ -12,11 +17,39 @@ export function noteCard(page: Page, title: string): Locator {
|
||||
/**
|
||||
* Extended test fixture that handles setup/auth.
|
||||
*/
|
||||
export const test = base.extend<{ authenticatedPage: Page }>({
|
||||
authenticatedPage: async ({ page }, use) => {
|
||||
export const test = base.extend<{ authenticatedPage: Page; collabPage: Page }>({
|
||||
authenticatedPage: async ({ page }, use, testInfo) => {
|
||||
// Setup: create user and login
|
||||
await setupAndLogin(page);
|
||||
await use(page);
|
||||
// Teardown: collect coverage data from the browser
|
||||
await collectCoverage(page, testInfo.title);
|
||||
},
|
||||
collabPage: async ({ browser, authenticatedPage }, use, testInfo) => {
|
||||
// Create collab user via admin API (idempotent)
|
||||
const res = await authenticatedPage.request.post('/api/admin/users', {
|
||||
data: {
|
||||
email: COLLAB_EMAIL,
|
||||
displayName: COLLAB_NAME,
|
||||
password: COLLAB_PASSWORD,
|
||||
role: 'user'
|
||||
}
|
||||
});
|
||||
// Ignore 409 — user already exists
|
||||
|
||||
// Login as collaborator in a new browser context
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
|
||||
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
|
||||
await page.getByTestId('login-btn').click();
|
||||
await page.waitForURL('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await use(page);
|
||||
// Teardown: collect coverage data from the browser
|
||||
await collectCoverage(page, testInfo.title + '-collab');
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,4 +90,4 @@ export async function setupAndLogin(page: Page) {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
export { expect, TEST_EMAIL, TEST_PASSWORD };
|
||||
export { expect, TEST_EMAIL, TEST_PASSWORD, COLLAB_EMAIL, COLLAB_PASSWORD, COLLAB_NAME };
|
||||
|
||||
@@ -47,23 +47,4 @@ test.describe('Notes CRUD', () => {
|
||||
// Then the note is no longer visible
|
||||
await expect(page.getByText('Delete Me')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Bold formatting applies to selected text in the editor', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a note with content "hello world"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await page.getByTestId('note-content-input').fill('hello world');
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
|
||||
// When the user selects "world" and applies bold formatting
|
||||
await page.getByTestId('tiptap-editor').evaluate((el) => {
|
||||
const ed = (el as any).__tiptapEditor;
|
||||
if (!ed) return;
|
||||
ed.chain().focus().setTextSelection({ from: 7, to: 12 }).toggleBold().run();
|
||||
});
|
||||
|
||||
// Then the selected text appears bold in the editor
|
||||
await expect(editor.locator('strong')).toHaveText('world');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ test.describe('Search', () => {
|
||||
|
||||
// When the user searches for "Grocery"
|
||||
await page.getByTestId('search-input').fill('Grocery');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Then "Grocery Errands" is visible in the results
|
||||
await expect(page.getByText('Grocery Errands')).toBeVisible();
|
||||
@@ -30,7 +29,6 @@ test.describe('Search', () => {
|
||||
|
||||
// When the user searches for "tomato"
|
||||
await page.getByTestId('search-input').fill('tomato');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Then "Recipe" is visible in the results
|
||||
await expect(page.getByText('Recipe')).toBeVisible();
|
||||
@@ -39,7 +37,6 @@ test.describe('Search', () => {
|
||||
test('Scenario: Searching for a nonexistent term yields no results', async ({ authenticatedPage: page }) => {
|
||||
// When the user searches for a term that matches nothing
|
||||
await page.getByTestId('search-input').fill('xyznonexistent');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Then no notes are displayed
|
||||
await expect(page.getByTestId('note-card')).toHaveCount(0);
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Settings — Preferences', () => {
|
||||
test('Scenario: Preferences tab is visible in settings nav', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// When the user navigates to settings
|
||||
await page.goto('/settings');
|
||||
|
||||
// Then the Preferences nav link is visible
|
||||
await expect(page.getByTestId('settings-nav-preferences')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Default note mode change applies to new notes', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user sets default note mode to Markdown
|
||||
await page.goto('/settings/preferences');
|
||||
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
||||
await page.getByTestId('pref-mode-markdown').click();
|
||||
await putResponse;
|
||||
|
||||
// When the user creates a new note
|
||||
await page.goto('/');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// Then the note editor opens in markdown mode (textarea visible)
|
||||
await expect(page.getByTestId('note-content-input')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Footer toggle hides and shows footer', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the footer is visible
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||
|
||||
// When the user enables the hide footer preference
|
||||
await page.goto('/settings/preferences');
|
||||
await page.getByTestId('pref-hide-footer').check();
|
||||
|
||||
// Then the footer is hidden
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('app-footer')).not.toBeVisible();
|
||||
|
||||
// When the user disables the hide footer preference
|
||||
await page.goto('/settings/preferences');
|
||||
await page.getByTestId('pref-hide-footer').uncheck();
|
||||
|
||||
// Then the footer is visible again
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Preferences persist across page reload', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user changes default note mode to Markdown
|
||||
await page.goto('/settings/preferences');
|
||||
await page.getByTestId('pref-mode-markdown').click();
|
||||
|
||||
// When the page is reloaded
|
||||
await page.reload();
|
||||
|
||||
// Then the Markdown button is still selected (has primary styling)
|
||||
const mdBtn = page.getByTestId('pref-mode-markdown');
|
||||
await expect(mdBtn).toHaveClass(/font-medium/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Settings — API Key Management', () => {
|
||||
test('Scenario: Created API key appears in the keys list', async ({
|
||||
authenticatedPage: page
|
||||
@@ -46,8 +114,8 @@ test.describe('Settings — API Key Management', () => {
|
||||
// When the user clicks the Settings link in the sidebar
|
||||
await page.getByTestId('settings-link').click();
|
||||
|
||||
// Then the settings page is shown
|
||||
await expect(page).toHaveURL('/settings');
|
||||
// Then the settings page redirects to preferences
|
||||
await expect(page).toHaveURL('/settings/preferences');
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -57,15 +125,15 @@ test.describe('Settings — API Key Management', () => {
|
||||
// Given the user is on the settings page
|
||||
await page.goto('/settings');
|
||||
|
||||
// When the user clicks the MCP Server nav link
|
||||
await page.getByRole('link', { name: 'MCP Server' }).click();
|
||||
// When the user clicks the API nav link
|
||||
await page.getByRole('link', { name: 'API' }).click();
|
||||
|
||||
// Then the MCP settings page is shown
|
||||
// Then the API settings page is shown
|
||||
await expect(page).toHaveURL('/settings/mcp');
|
||||
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
|
||||
|
||||
// When the user clicks back to Profile
|
||||
await page.getByRole('link', { name: 'Profile' }).click();
|
||||
await expect(page).toHaveURL('/settings');
|
||||
await expect(page).toHaveURL('/settings/profile');
|
||||
});
|
||||
});
|
||||
|
||||
+110
-186
@@ -1,233 +1,157 @@
|
||||
import { test, expect, noteCard, TEST_EMAIL, TEST_PASSWORD } from './helpers/fixtures.js';
|
||||
import type { Page, BrowserContext } from '@playwright/test';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Create a second user via the admin API (using the admin's session cookies).
|
||||
* Create a note via API and share it with the collaborator.
|
||||
* Returns the note object (with id).
|
||||
*/
|
||||
async function createCollaboratorUser(adminPage: Page) {
|
||||
const cookies = await adminPage.context().cookies();
|
||||
const sessionCookie = cookies.find((c) => c.name === 'session');
|
||||
if (!sessionCookie) throw new Error('No session cookie found');
|
||||
|
||||
const res = await adminPage.request.post('/api/admin/users', {
|
||||
data: {
|
||||
email: COLLAB_EMAIL,
|
||||
displayName: COLLAB_NAME,
|
||||
password: COLLAB_PASSWORD,
|
||||
role: 'user'
|
||||
}
|
||||
async function createSharedNote(
|
||||
ownerPage: Page,
|
||||
collabPage: Page,
|
||||
title: string,
|
||||
content = 'Shared content'
|
||||
) {
|
||||
// Create note
|
||||
const res = await ownerPage.request.post('/api/notes', {
|
||||
data: { title, content }
|
||||
});
|
||||
if (!res.ok()) {
|
||||
const body = await res.text();
|
||||
// User may already exist from prior test
|
||||
if (!body.includes('already exists') && res.status() !== 409) {
|
||||
throw new Error(`Failed to create collaborator user: ${res.status()} ${body}`);
|
||||
}
|
||||
}
|
||||
const note = await res.json();
|
||||
|
||||
// Find the collab user ID
|
||||
const usersRes = await ownerPage.request.get('/api/users/search?q=collab');
|
||||
const users = await usersRes.json();
|
||||
const collabUser = users[0];
|
||||
|
||||
// Share the note
|
||||
await ownerPage.request.post(`/api/notes/${note.id}/collaborators`, {
|
||||
data: { userId: collabUser.id }
|
||||
});
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the collaborator user in a new browser context.
|
||||
*/
|
||||
async function loginAsCollaborator(browser: import('@playwright/test').Browser): Promise<{ context: BrowserContext; page: Page }> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
|
||||
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
|
||||
await page.getByTestId('login-btn').click();
|
||||
await page.waitForURL('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
test.describe.serial('Note Sharing', () => {
|
||||
let collabContext: BrowserContext;
|
||||
let collabPage: Page;
|
||||
|
||||
test('Scenario: Owner shares a note with another user', async ({ authenticatedPage: page, browser }) => {
|
||||
// Given a second user exists
|
||||
await createCollaboratorUser(page);
|
||||
|
||||
// When the owner creates a note titled "Shared Note"
|
||||
test.describe('Note Sharing', () => {
|
||||
test('Scenario: Owner shares a note via UI and collaborator sees it', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// When the owner creates a note titled "Share Test"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Shared Note');
|
||||
await page.getByTestId('note-title-input').fill('Share Test');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Shared content');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Shared Note')).toBeVisible();
|
||||
await expect(noteCard(page, 'Share Test')).toBeVisible();
|
||||
|
||||
// And the owner opens the share dialog
|
||||
await noteCard(page, 'Shared Note').click();
|
||||
// And the owner opens the share dialog and adds the collaborator
|
||||
await noteCard(page, 'Share Test').click();
|
||||
await page.getByTestId('share-toggle').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
|
||||
// And the owner searches for the collaborator
|
||||
await page.getByTestId('share-search-input').fill('collab');
|
||||
await expect(page.getByTestId('share-user-result').first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// And the owner adds the collaborator
|
||||
await page.getByTestId('share-user-result').first().click();
|
||||
|
||||
// Then the collaborator appears in the share dialog
|
||||
await expect(page.getByTestId('share-collaborator')).toBeVisible();
|
||||
|
||||
// Close dialogs
|
||||
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note shows a collaborator indicator
|
||||
const card = noteCard(page, 'Shared Note');
|
||||
await expect(card.getByTestId('collaborator-indicator')).toBeVisible();
|
||||
// Then the owner sees a collaborator indicator
|
||||
await expect(noteCard(page, 'Share Test').getByTestId('collaborator-indicator')).toBeVisible();
|
||||
|
||||
// And the collaborator sees the shared note in their list
|
||||
await collabPage.reload();
|
||||
await collabPage.waitForLoadState('networkidle');
|
||||
await expect(noteCard(collabPage, 'Share Test')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees the shared note in their list', async ({ authenticatedPage: page, browser }) => {
|
||||
// Given the note was shared in the previous test
|
||||
// When the collaborator logs in
|
||||
const collab = await loginAsCollaborator(browser);
|
||||
collabContext = collab.context;
|
||||
collabPage = collab.page;
|
||||
test('Scenario: Collaborator can edit shared note and owner sees changes', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared note exists
|
||||
const note = await createSharedNote(page, collabPage, 'Collab Edit Test');
|
||||
|
||||
// Then the collaborator sees "Shared Note" in their notes list
|
||||
await expect(noteCard(collabPage, 'Shared Note')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// And the note shows a collaborator indicator
|
||||
await expect(noteCard(collabPage, 'Shared Note').getByTestId('collaborator-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator can edit the shared note content', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator edits the note
|
||||
await noteCard(collabPage, 'Shared Note').click();
|
||||
const editor = collabPage.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially(' — edited by collaborator');
|
||||
await collabPage.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And the owner reloads
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the owner sees the collaborator's changes
|
||||
await noteCard(page, 'Shared Note').click();
|
||||
await expect(page.getByTestId('tiptap-editor')).toContainText('edited by collaborator');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Owner and collaborator have independent pin state', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator pins the shared note
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
await card.getByTestId('pin-btn').click({ force: true });
|
||||
|
||||
// Then the collaborator sees the note as pinned
|
||||
await expect(noteCard(collabPage, 'Shared Note').getByTestId('pin-indicator')).toBeVisible();
|
||||
|
||||
// But the owner does not see it as pinned
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const ownerCard = noteCard(page, 'Shared Note');
|
||||
await expect(ownerCard.getByTestId('pin-indicator')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Owner edits content and collaborator sees updated content via sync', async ({ authenticatedPage: page }) => {
|
||||
// When the owner edits the shared note content via the API
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
expect(sharedNote).toBeDefined();
|
||||
|
||||
await page.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { content: 'Content updated by owner for sync test' }
|
||||
// When the collaborator edits the note via API
|
||||
await collabPage.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { content: 'Edited by collaborator' }
|
||||
});
|
||||
|
||||
// Then the collaborator sees the updated content after fetching from the API
|
||||
const collabNotesRes = await collabPage.request.get('/api/notes');
|
||||
const collabNotes = await collabNotesRes.json();
|
||||
const collabSharedNote = collabNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(collabSharedNote).toBeDefined();
|
||||
expect(collabSharedNote.content).toBe('Content updated by owner for sync test');
|
||||
|
||||
// And the sync endpoint also returns the change for the collaborator
|
||||
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Content updated by owner for sync test');
|
||||
// Then the owner sees the updated content
|
||||
const ownerRes = await page.request.get(`/api/notes/${note.id}`);
|
||||
const ownerNote = await ownerRes.json();
|
||||
expect(ownerNote.content).toBe('Edited by collaborator');
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator edits content and owner sees updated content via sync', async ({ authenticatedPage: page }) => {
|
||||
// Given we have the shared note ID
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
test('Scenario: Owner and collaborator have independent per-user state', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared note exists
|
||||
const note = await createSharedNote(page, collabPage, 'Pin Independence Test');
|
||||
|
||||
// When the collaborator edits the content via the API
|
||||
await collabPage.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { content: 'Content updated by collaborator for sync test' }
|
||||
});
|
||||
|
||||
// Then the owner sees the updated content from the sync endpoint
|
||||
const syncRes = await page.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Content updated by collaborator for sync test');
|
||||
});
|
||||
|
||||
test('Scenario: Sync returns per-user state overlay for collaborators', async ({ authenticatedPage: page }) => {
|
||||
// Given the shared note exists
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
|
||||
// And the owner pins the note (owner's pin state)
|
||||
await page.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
// When the collaborator pins the note
|
||||
await collabPage.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { pinned: true }
|
||||
});
|
||||
|
||||
// Then the sync endpoint returns the collaborator's per-user state (not owner's)
|
||||
const collabSyncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const collabSyncedNotes = await collabSyncRes.json();
|
||||
const collabSyncedNote = collabSyncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
// Then the collaborator sees it as pinned
|
||||
const collabRes = await collabPage.request.get(`/api/notes/${note.id}`);
|
||||
const collabNote = await collabRes.json();
|
||||
expect(collabNote.pinned).toBeTruthy();
|
||||
|
||||
// Collaborator should see their own pinned state (from noteUserState), not the owner's
|
||||
// The collaborator pinned this note in a previous test, so it should be true for them
|
||||
// but via their own per-user state, not leaked from the owner
|
||||
expect(collabSyncedNote).toBeDefined();
|
||||
// But the owner does not see it as pinned
|
||||
const ownerRes = await page.request.get(`/api/notes/${note.id}`);
|
||||
const ownerNote = await ownerRes.json();
|
||||
expect(ownerNote.pinned).toBeFalsy();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees leave action instead of trash', async () => {
|
||||
// Then the collaborator's card shows a leave button, not a trash button
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
test('Scenario: Sync returns correct data for both owner and collaborator', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared note exists
|
||||
const note = await createSharedNote(page, collabPage, 'Sync Round-trip Test');
|
||||
|
||||
// When the owner updates the content
|
||||
await page.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { content: 'Owner sync update' }
|
||||
});
|
||||
|
||||
// Then the collaborator sees the change via sync endpoint
|
||||
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === note.id);
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Owner sync update');
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees leave action and can leave a shared note', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared note exists
|
||||
const note = await createSharedNote(page, collabPage, 'Leave Test Note');
|
||||
|
||||
// When the collaborator views their notes
|
||||
await collabPage.reload();
|
||||
await collabPage.waitForLoadState('networkidle');
|
||||
await expect(noteCard(collabPage, 'Leave Test Note')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Then the collaborator's card shows a leave button, not trash
|
||||
const card = noteCard(collabPage, 'Leave Test Note');
|
||||
await card.hover();
|
||||
await expect(card.getByTestId('leave-btn')).toBeVisible();
|
||||
await expect(card.getByTestId('trash-btn')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator leaves shared note and it disappears', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator leaves the shared note
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
// When the collaborator leaves
|
||||
await card.getByTestId('leave-btn').click({ force: true });
|
||||
|
||||
// Then the note disappears from the collaborator's list
|
||||
await expect(noteCard(collabPage, 'Shared Note')).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(noteCard(collabPage, 'Leave Test Note')).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// But the owner still sees it
|
||||
// But the owner still has it
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(noteCard(page, 'Shared Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (collabContext) await collabContext.close();
|
||||
await expect(noteCard(page, 'Leave Test Note')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import istanbul from 'vite-plugin-istanbul';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -10,6 +11,17 @@ export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
...(process.env.VITE_COVERAGE === 'true'
|
||||
? [
|
||||
istanbul({
|
||||
include: 'src/**/*',
|
||||
exclude: ['node_modules', 'tests/', '**/*.test.ts'],
|
||||
extension: ['.ts', '.svelte'],
|
||||
requireEnv: true,
|
||||
forceBuildInstrument: true
|
||||
})
|
||||
]
|
||||
: []),
|
||||
SvelteKitPWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
|
||||
+8
-1
@@ -9,6 +9,13 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov', 'json-summary'],
|
||||
reportsDirectory: './coverage/unit',
|
||||
include: ['src/lib/**/*.ts'],
|
||||
exclude: ['src/lib/**/*.test.ts', 'src/lib/types/**']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+3
-3
@@ -145,8 +145,8 @@
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>
|
||||
</div>
|
||||
<h3>MCP Server</h3>
|
||||
<p>Built-in AI integration. Connect Claude, Cursor, or any MCP client to read and write your notes.</p>
|
||||
<h3>API & MCP</h3>
|
||||
<p>Full REST API with token auth. Integrate with n8n, Zapier, or scripts. Built-in MCP server for AI tools like Claude and Cursor.</p>
|
||||
</div>
|
||||
|
||||
<!-- Multi-User -->
|
||||
@@ -253,7 +253,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.14.0</span>
|
||||
<span class="footer-version">v0.18.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user