mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
+38
-16
@@ -46,11 +46,24 @@ 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
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -65,24 +78,26 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright System Dependencies
|
||||
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'
|
||||
HOME: /root
|
||||
|
||||
- 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 +106,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"]
|
||||
}
|
||||
@@ -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.15.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.19.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
# E2E BDD Testing Best Practices
|
||||
|
||||
This document defines the 10 best practices for writing E2E tests in Crumbs. All tests use Playwright with Gherkin-style BDD comments.
|
||||
|
||||
## 1. Declarative scenarios, not imperative scripts
|
||||
|
||||
Describe **what** the system does, not **how** the user clicks through the UI.
|
||||
|
||||
```
|
||||
// Good: "When the user creates a note titled 'Shopping List'"
|
||||
// Bad: "When the user clicks the + button, types 'Shopping List' in the title field, and clicks save"
|
||||
```
|
||||
|
||||
If the UI changes (button moves, field renames), only the Playwright code changes — the scenario comment stays stable.
|
||||
|
||||
## 2. Given describes state, not navigation
|
||||
|
||||
`Given` sets up preconditions. It should read like a database fixture, not a user journey.
|
||||
|
||||
```
|
||||
// Good: "Given a note titled 'Old Note' exists in the trash"
|
||||
// Bad: "Given the user creates a note, then clicks trash, then confirms deletion"
|
||||
```
|
||||
|
||||
Use API calls or helpers like `createNote()` in `Given` steps to avoid coupling tests to unrelated UI flows.
|
||||
|
||||
## 3. One behavior per scenario
|
||||
|
||||
Each `test()` should verify **one outcome**. If you need multiple `Then` assertions about different behaviors, split them.
|
||||
|
||||
```ts
|
||||
// Good: Two focused scenarios
|
||||
test('Trashed note disappears from main view', ...)
|
||||
test('Trashed note appears in trash view', ...)
|
||||
|
||||
// Bad: One bloated scenario testing everything
|
||||
test('Trash workflow', ...) // creates, trashes, checks main, checks trash, restores...
|
||||
```
|
||||
|
||||
## 4. Scenario names describe outcomes, not actions
|
||||
|
||||
Name tests by the **observable result**, not the user action that triggers it.
|
||||
|
||||
```ts
|
||||
// Good: test('Archived note no longer appears in the main view')
|
||||
// Bad: test('User archives a note')
|
||||
```
|
||||
|
||||
This makes test reports read like a behavior specification.
|
||||
|
||||
## 5. Isolate test state completely
|
||||
|
||||
Each test should start from a **known, clean state**. Use API-based setup/teardown or the shared `authenticatedPage` fixture.
|
||||
|
||||
```ts
|
||||
// Use API to create/reset state before each test
|
||||
test.beforeEach(async ({ authenticatedPage: page }) => {
|
||||
await page.request.put('/api/preferences', { data: { ... } });
|
||||
});
|
||||
```
|
||||
|
||||
This prevents the "test B fails because test A didn't clean up" problem — especially important since Playwright runs workers in parallel.
|
||||
|
||||
## 6. Use `test.describe.serial` only when truly sequential
|
||||
|
||||
Reserve `serial` for flows where state genuinely cascades (setup -> login -> session). For everything else, isolate with fixtures. Treat `serial` as a **code smell** — most tests should run in **any order**.
|
||||
|
||||
## 7. Abstract repeated UI actions into helpers
|
||||
|
||||
When multiple scenarios share the same UI interaction (e.g., "create a note"), extract it into a helper at the **intent level**.
|
||||
|
||||
```ts
|
||||
// helpers/fixtures.ts
|
||||
export async function createNote(page, title, content?) { ... }
|
||||
|
||||
// In tests — reads like BDD
|
||||
await createNote(page, 'My Note', 'Hello');
|
||||
```
|
||||
|
||||
The shared `authenticatedPage` fixture and `noteCard()` locator are examples of this pattern. The `createNote()` helper abstracts the 4-step note creation flow used across most test files.
|
||||
|
||||
## 8. Apply the resilience test to every comment
|
||||
|
||||
Before committing a Gherkin comment, ask: *"Would this comment need to change if we redesigned the UI?"*
|
||||
|
||||
- If **yes** — rewrite to remove implementation details
|
||||
- If **no** — it's at the right abstraction level
|
||||
|
||||
```
|
||||
// Fails (mentions hover + button):
|
||||
// "When the user hovers the card and clicks the 3-dot menu"
|
||||
|
||||
// Passes:
|
||||
// "When the user opens the note actions menu"
|
||||
```
|
||||
|
||||
## 9. Prefer `await expect(...).toBeVisible()` over arbitrary waits
|
||||
|
||||
Playwright's auto-waiting is powerful. Never use `page.waitForTimeout()` — it's flaky and slow. Assert on the **expected end state** instead.
|
||||
|
||||
```ts
|
||||
// Good: Waits intelligently for the element
|
||||
await expect(page.getByText('Note saved')).toBeVisible();
|
||||
|
||||
// Bad: Arbitrary delay
|
||||
await page.waitForTimeout(2000);
|
||||
```
|
||||
|
||||
The exception is `waitForLoadState('networkidle')` after authentication redirects, where the full page reload makes auto-waiting insufficient.
|
||||
|
||||
## 10. Structure tests in the Given/When/Then rhythm
|
||||
|
||||
Use comments to maintain the three-act structure. This makes tests scannable and self-documenting.
|
||||
|
||||
```ts
|
||||
test('Pinned note appears at the top of the list', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Important" exists
|
||||
await createNote(page, 'Important');
|
||||
|
||||
// When the user pins the note
|
||||
await pinNote(page, 'Important');
|
||||
|
||||
// Then it appears before unpinned notes
|
||||
const firstCard = page.locator('[data-testid="note-card"]').first();
|
||||
await expect(firstCard).toContainText('Important');
|
||||
});
|
||||
```
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Practice | Category | Goal |
|
||||
|----------|----------|------|
|
||||
| 1. Declarative scenarios | Language | Stable comments despite UI changes |
|
||||
| 2. Given = state | Language | Decouple setup from UI flow |
|
||||
| 3. One behavior per test | Structure | Focused, debuggable tests |
|
||||
| 4. Outcome-based names | Language | Test reports read as specs |
|
||||
| 5. Isolate state | Reliability | No cross-test pollution |
|
||||
| 6. Avoid serial | Reliability | Parallel-safe by default |
|
||||
| 7. Extract helpers | Structure | DRY, intent-level code |
|
||||
| 8. Resilience test | Language | UI-change-proof comments |
|
||||
| 9. Auto-wait assertions | Reliability | No flaky timeouts |
|
||||
| 10. Given/When/Then | Structure | Scannable three-act tests |
|
||||
@@ -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.16.0",
|
||||
"version": "0.19.2",
|
||||
"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
|
||||
@@ -6,10 +6,9 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
let { open, onClose, appVersion }: Props = $props();
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
function closeMobile() {
|
||||
if (window.matchMedia('(max-width: 1023px)').matches) {
|
||||
@@ -75,17 +74,12 @@
|
||||
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex w-full items-center gap-3 px-6 py-3 text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--primary)]"
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$page.url.pathname.startsWith('/settings') ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
onclick={closeMobile}
|
||||
data-testid="settings-link"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<Settings size={20} />
|
||||
Settings
|
||||
</a>
|
||||
{#if appVersion}
|
||||
<div class="border-t border-[var(--border-subtle)] px-6 py-2 text-right text-xs text-[var(--text-muted)]">
|
||||
v{appVersion}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -26,7 +34,7 @@
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
|
||||
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
|
||||
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
|
||||
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
|
||||
|
||||
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
<div class="mx-auto max-w-7xl px-4">
|
||||
@@ -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, Info } 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
|
||||
@@ -46,6 +56,15 @@
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<a
|
||||
href="/settings/about"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/about' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Info size={16} />
|
||||
About
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
redirect(302, '/settings/preferences');
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let appVersion = $derived($page.data.appVersion);
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-bold text-[var(--text)]">About Crumbs</h2>
|
||||
<span
|
||||
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-2 py-0.5 text-xs font-medium text-[var(--text-muted)]"
|
||||
>
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">What is Crumbs?</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Crumbs is a self-hostable note-taking app. Like breadcrumbs left along a trail, Crumbs
|
||||
captures the small scattered thoughts, ideas, and notes you leave behind as you go — so you
|
||||
can always find your way back to them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">Bretzel App Universe</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Crumbs is part of the
|
||||
<a
|
||||
href="https://bretzel.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">Bretzel</a
|
||||
> family of apps.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">Open Source</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Crumbs is licensed under the MIT License. Source code is available on
|
||||
<a
|
||||
href="https://github.com/bretzel-app/crumbs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">GitHub</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">Feedback</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Feedback is welcome! Please open an issue on
|
||||
<a
|
||||
href="https://github.com/bretzel-app/crumbs/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">GitHub Issues</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">Tech Stack</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Built with SvelteKit, SQLite, and Drizzle ORM. Offline-first PWA with CRDT-based sync.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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) ?? [],
|
||||
|
||||
@@ -72,7 +72,7 @@ test.describe('Image Attachments', () => {
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user features the image by clicking the star
|
||||
// When the user features the image
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
|
||||
@@ -91,8 +91,6 @@ test.describe('Image Attachments', () => {
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// Feature the image
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
@@ -155,7 +153,7 @@ test.describe('Image Attachments', () => {
|
||||
});
|
||||
|
||||
test('Scenario: Image toggle on new note auto-saves and opens upload panel', async ({ authenticatedPage: page }) => {
|
||||
// When the user opens a new note and clicks the image toggle
|
||||
// When the user opens a new note and enables the image panel
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-Save Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
@@ -163,10 +161,8 @@ test.describe('Image Attachments', () => {
|
||||
// Then the upload panel is visible (note was auto-saved)
|
||||
await expect(page.getByTestId('image-upload')).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
// And the note persists in the list after closing
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And the note exists in the list
|
||||
await expect(noteCard(page, 'Auto-Save Test')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,10 +70,10 @@ test.describe.serial('Authentication', () => {
|
||||
});
|
||||
|
||||
test.describe('Multi-user', () => {
|
||||
test('admin can create a new user via API', async ({ authenticatedPage: page }) => {
|
||||
// authenticatedPage fixture already handles setup/login
|
||||
test('Scenario: Admin-created user is registered successfully', async ({ authenticatedPage: page }) => {
|
||||
// Given the admin is authenticated
|
||||
|
||||
// Create a new user via admin API
|
||||
// When the admin creates a new user
|
||||
const response = await page.request.post('/api/admin/users', {
|
||||
data: {
|
||||
email: 'user2@test.com',
|
||||
@@ -83,6 +83,7 @@ test.describe('Multi-user', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Then the user is created successfully
|
||||
expect(response.status()).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
+37
-33
@@ -1,7 +1,23 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** Create a checklist note via UI. Leaves the editor closed. */
|
||||
async function createChecklistNote(page: Page, title: string, items: string[]) {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill(title);
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
|
||||
if (i < items.length - 1) {
|
||||
await page.getByTestId('checklist-input').nth(i).press('Enter');
|
||||
}
|
||||
}
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, title)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Checklist', () => {
|
||||
test('Scenario: Checklist UI replaces editor when checklist mode is enabled', async ({ authenticatedPage: page }) => {
|
||||
test('Scenario: Checklist replaces the rich text editor when enabled', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is creating a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
@@ -15,16 +31,9 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('tiptap-editor')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Checklist item can be added and persisted', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a checklist note with an item "Buy milk"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Shopping List');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Buy milk');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note appears in the list
|
||||
await expect(page.getByText('Shopping List')).toBeVisible();
|
||||
test('Scenario: Checklist item persists after closing and reopening the note', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with an item "Buy milk" exists
|
||||
await createChecklistNote(page, 'Shopping List', ['Buy milk']);
|
||||
|
||||
// When the user reopens the note
|
||||
await noteCard(page, 'Shopping List').click();
|
||||
@@ -34,13 +43,9 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
|
||||
});
|
||||
|
||||
test('Scenario: Checking an item marks it as completed', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with an item "Buy milk"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Tasks');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Buy milk');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with an item "Buy milk" exists
|
||||
await createChecklistNote(page, 'Tasks', ['Buy milk']);
|
||||
|
||||
// When the user reopens the note and checks the item
|
||||
await noteCard(page, 'Tasks').click();
|
||||
@@ -50,13 +55,13 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And reopening the note shows the item in the done section (expanded by default)
|
||||
// And the done state persists after reopening
|
||||
await noteCard(page, 'Tasks').click();
|
||||
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
||||
});
|
||||
|
||||
test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a checklist note
|
||||
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with one item
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First item');
|
||||
@@ -68,46 +73,45 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('Scenario: Empty checklist item is removed by pressing Backspace', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with two items
|
||||
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with two items where the second is empty
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First item');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
|
||||
// When the user presses Backspace on the empty second item
|
||||
// When the user presses Backspace on the empty item
|
||||
await page.getByTestId('checklist-input').nth(1).press('Backspace');
|
||||
|
||||
// Then only the first item remains
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Scenario: Completed checklist items are separated and can be expanded', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with a completed item and an uncompleted item
|
||||
test('Scenario: Completed items are separated into a done section', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with two items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Hide Done Test');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Done task');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
|
||||
|
||||
// When the user completes the first item
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then the completed item is separated from the active list
|
||||
// Then only the pending item remains in the active list
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
|
||||
|
||||
// And the "done" toggle shows the count
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toBeVisible();
|
||||
// And the done section shows the completed item
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
|
||||
// Then the completed items are shown in the done section (expanded by default)
|
||||
await expect(page.getByTestId('checklist-done-section')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
|
||||
});
|
||||
|
||||
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => {
|
||||
// Given the user has checklist mode enabled
|
||||
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
|
||||
// Given checklist mode is enabled on a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
|
||||
@@ -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,17 @@ export async function setupAndLogin(page: Page) {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
export { expect, TEST_EMAIL, TEST_PASSWORD };
|
||||
/** Create a note via the UI and close the editor. Use in Given steps for state setup. */
|
||||
export async function createNote(page: Page, title: string, content?: string) {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill(title);
|
||||
if (content) {
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially(content);
|
||||
}
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, title)).toBeVisible();
|
||||
}
|
||||
|
||||
export { expect, TEST_EMAIL, TEST_PASSWORD, COLLAB_EMAIL, COLLAB_PASSWORD, COLLAB_NAME };
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Notes CRUD', () => {
|
||||
test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a note titled "My First Note" with content "Hello world!"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('My First Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Hello world!');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'My First Note', 'Hello world!');
|
||||
|
||||
// Then the note is visible in the notes list
|
||||
await expect(noteCard(page, 'My First Note')).toBeVisible();
|
||||
@@ -16,12 +11,7 @@ test.describe('Notes CRUD', () => {
|
||||
|
||||
test('Scenario: Edited note title is reflected in the notes list', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Original Title" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Original Title');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Original content');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Original Title', 'Original content');
|
||||
|
||||
// When the user changes the title to "Updated Title"
|
||||
await noteCard(page, 'Original Title').click();
|
||||
@@ -35,9 +25,7 @@ test.describe('Notes CRUD', () => {
|
||||
|
||||
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Delete Me" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Delete Me');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Delete Me');
|
||||
|
||||
// When the user trashes the note
|
||||
const card = noteCard(page, 'Delete Me');
|
||||
@@ -47,23 +35,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');
|
||||
});
|
||||
});
|
||||
|
||||
+16
-31
@@ -1,7 +1,7 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Offline Support', () => {
|
||||
test('Scenario: Sync status is visible to the user', async ({ authenticatedPage: page }) => {
|
||||
test('Scenario: Sync status indicator is visible on the main page', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is authenticated
|
||||
// Then the sync status indicator is displayed
|
||||
await expect(page.getByTestId('sync-indicator')).toBeVisible();
|
||||
@@ -9,9 +9,7 @@ test.describe('Offline Support', () => {
|
||||
|
||||
test('Scenario: Created note is immediately visible in the notes list', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a note titled "Offline Test Note"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Offline Test Note');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Offline Test Note');
|
||||
|
||||
// Then the note appears in the list
|
||||
await expect(page.getByText('Offline Test Note')).toBeVisible();
|
||||
@@ -19,10 +17,7 @@ test.describe('Offline Support', () => {
|
||||
|
||||
test('Scenario: Edited note persists in the UI when offline', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Offline Edit" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Offline Edit');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByText('Offline Edit')).toBeVisible();
|
||||
await createNote(page, 'Offline Edit');
|
||||
|
||||
// When the network is unavailable
|
||||
await page.context().setOffline(true);
|
||||
@@ -33,10 +28,10 @@ test.describe('Offline Support', () => {
|
||||
await page.getByTestId('note-title-input').fill('Offline Edited');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note "Offline Edited" appears in the list
|
||||
// Then the updated note appears in the list
|
||||
await expect(page.getByText('Offline Edited')).toBeVisible();
|
||||
|
||||
// And a "Saved offline" toast is shown
|
||||
// And an offline save confirmation is shown
|
||||
await expect(page.getByText('Saved offline')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -49,27 +44,25 @@ test.describe('Offline Support', () => {
|
||||
await page.getByTestId('note-title-input').fill('Offline New');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note "Offline New" appears in the list
|
||||
// Then the note appears in the list
|
||||
await expect(page.getByText('Offline New')).toBeVisible();
|
||||
|
||||
// And a "Saved offline" toast is shown
|
||||
// And an offline save confirmation is shown
|
||||
await expect(page.getByText('Saved offline')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Offline note survives a page reload after reconnecting', async ({ authenticatedPage: page }) => {
|
||||
// When the network is unavailable
|
||||
// Given the network is unavailable
|
||||
await page.context().setOffline(true);
|
||||
|
||||
// And the user creates a note titled "Survive Reload"
|
||||
// And a note titled "Survive Reload" is created offline
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Survive Reload');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByText('Survive Reload')).toBeVisible();
|
||||
|
||||
// When the network is restored (triggers 'online' event + navigator.onLine = true)
|
||||
// When the network is restored and sync completes
|
||||
await page.context().setOffline(false);
|
||||
|
||||
// And the sync completes
|
||||
await page.waitForResponse('**/api/sync');
|
||||
|
||||
// And the user reloads the page
|
||||
@@ -81,27 +74,19 @@ test.describe('Offline Support', () => {
|
||||
|
||||
test('Scenario: Notes load from local cache when the API is unavailable', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Cached Note" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Cached Note');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByText('Cached Note')).toBeVisible();
|
||||
await createNote(page, 'Cached Note');
|
||||
|
||||
// When the API is unavailable (simulating service worker serving cached shell)
|
||||
// When the API becomes unavailable and the page is reloaded
|
||||
await page.route('**/api/**', (route) => route.abort());
|
||||
|
||||
// And the user reloads the page
|
||||
await page.reload();
|
||||
|
||||
// Then the note is still visible (loaded from IDB cache)
|
||||
// Then the note is still visible from the local cache
|
||||
await expect(page.getByText('Cached Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Offline Trash" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Offline Trash');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByText('Offline Trash')).toBeVisible();
|
||||
await createNote(page, 'Offline Trash');
|
||||
|
||||
// When the network is unavailable
|
||||
await page.context().setOffline(true);
|
||||
@@ -114,7 +99,7 @@ test.describe('Offline Support', () => {
|
||||
// Then the note is no longer visible
|
||||
await expect(page.getByText('Offline Trash')).not.toBeVisible();
|
||||
|
||||
// And a "Saved offline" toast is shown
|
||||
// And an offline save confirmation is shown
|
||||
await expect(page.getByText('Saved offline')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Organization Features', () => {
|
||||
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Pin Me" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Pin Me');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Pin Me');
|
||||
|
||||
// When the user pins the note
|
||||
const pinCard = noteCard(page, 'Pin Me');
|
||||
@@ -18,9 +16,7 @@ test.describe('Organization Features', () => {
|
||||
|
||||
test('Scenario: Archived note is removed from the main view', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Archive Me" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Archive Me');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Archive Me');
|
||||
|
||||
// When the user archives the note
|
||||
const archiveCard = noteCard(page, 'Archive Me');
|
||||
@@ -31,58 +27,42 @@ test.describe('Organization Features', () => {
|
||||
await expect(page.getByText('Archive Me')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Note color is changed via the color picker', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is creating a note titled "Colored Note"
|
||||
test('Scenario: Note card reflects the selected color', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is creating a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Colored Note');
|
||||
|
||||
// When the user sets the note color to "Coral"
|
||||
// When the user selects the "Coral" color
|
||||
await page.getByTestId('color-picker-toggle').click();
|
||||
await page.getByTestId('color-coral').click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note is saved and visible in the notes list
|
||||
// Then the note is visible in the notes list
|
||||
await expect(noteCard(page, 'Colored Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => {
|
||||
// Given a note tagged #important and an untagged note exist
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Tagged Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('This is #important');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Untagged Note');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Tagged Note', 'This is #important');
|
||||
await createNote(page, 'Untagged Note');
|
||||
|
||||
// When the user filters by the #important tag
|
||||
const tagChip = page.getByTestId('tag-filter').getByTestId('tag-chip').filter({ hasText: '#important' });
|
||||
if (await tagChip.isVisible()) {
|
||||
await tagChip.click();
|
||||
await expect(tagChip).toBeVisible();
|
||||
await tagChip.click();
|
||||
|
||||
// Then only the tagged note is visible
|
||||
await expect(page.getByText('Tagged Note').first()).toBeVisible();
|
||||
}
|
||||
// Then only the tagged note is visible
|
||||
await expect(page.getByText('Tagged Note').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Note Sorting', () => {
|
||||
test('Scenario: Most recently updated note appears first', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist with different update times
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-First');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-First')).toBeVisible();
|
||||
// Given two notes exist created at different times
|
||||
await createNote(page, 'Sort-First');
|
||||
await createNote(page, 'Sort-Second');
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-Second');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-Second')).toBeVisible();
|
||||
|
||||
// Then Sort-Second (most recently updated) appears before Sort-First
|
||||
// Then the most recently created note appears before the older one
|
||||
const sortFirst = noteCard(page, 'Sort-First');
|
||||
const sortSecond = noteCard(page, 'Sort-Second');
|
||||
const firstBox = await sortSecond.boundingBox();
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { test, expect, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Search', () => {
|
||||
test('Scenario: Searching by title returns the matching note', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Grocery Errands" and a note titled "Work Meeting" exist
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Grocery Errands');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Work Meeting');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Grocery Errands');
|
||||
await createNote(page, 'Work Meeting');
|
||||
|
||||
// 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();
|
||||
@@ -21,16 +15,10 @@ test.describe('Search', () => {
|
||||
|
||||
test('Scenario: Searching by content returns the matching note', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Recipe" with content "Pasta with tomato sauce" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Recipe');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Pasta with tomato sauce');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Recipe', 'Pasta with tomato sauce');
|
||||
|
||||
// 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 +27,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);
|
||||
|
||||
+149
-15
@@ -1,12 +1,143 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const DEFAULT_PREFS = {
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open'
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset preferences to defaults on both server and client localStorage.
|
||||
*
|
||||
* Server reset ensures GET /api/preferences returns defaults.
|
||||
* localStorage reset ensures initPreferences() picks up correct values
|
||||
* even if the sync request is blocked or returns stale data.
|
||||
*/
|
||||
async function resetPreferences(page: Page) {
|
||||
await page.request.put('/api/preferences', {
|
||||
data: {
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: 'false',
|
||||
sidebarDefaultState: 'open'
|
||||
}
|
||||
});
|
||||
await page.evaluate(
|
||||
(prefs) => localStorage.setItem('crumbs-preferences', JSON.stringify(prefs)),
|
||||
DEFAULT_PREFS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a URL with reliable preference state.
|
||||
*
|
||||
* page.goto() triggers a full page reload which re-runs initPreferences():
|
||||
* 1. loadFromLocalStorage() — synchronous, returns correct value
|
||||
* 2. syncPreferencesFromServer() — async, can return stale data from
|
||||
* parallel test workers that share the same user's server-side prefs
|
||||
*
|
||||
* This helper blocks the one GET /api/preferences sync request so
|
||||
* initPreferences() falls back to localStorage (which is always correct
|
||||
* from either resetPreferences or updatePreference on the settings page).
|
||||
*/
|
||||
async function gotoWithStablePrefs(page: Page, url: string) {
|
||||
await page.route(
|
||||
'**/api/preferences',
|
||||
(route) => route.abort(),
|
||||
{ times: 1 }
|
||||
);
|
||||
await page.goto(url);
|
||||
}
|
||||
|
||||
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
|
||||
}) => {
|
||||
await resetPreferences(page);
|
||||
|
||||
// Given the user sets default note mode to Markdown
|
||||
await gotoWithStablePrefs(page, '/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 gotoWithStablePrefs(page, '/');
|
||||
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
|
||||
}) => {
|
||||
await resetPreferences(page);
|
||||
|
||||
// Given the footer is visible
|
||||
await gotoWithStablePrefs(page, '/');
|
||||
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||
|
||||
// When the user enables the hide footer preference
|
||||
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||
await page.getByTestId('pref-hide-footer').check();
|
||||
|
||||
// Then the footer is hidden
|
||||
await gotoWithStablePrefs(page, '/');
|
||||
await expect(page.getByTestId('app-footer')).not.toBeVisible();
|
||||
|
||||
// When the user disables the hide footer preference
|
||||
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||
await page.getByTestId('pref-hide-footer').uncheck();
|
||||
|
||||
// Then the footer is visible again
|
||||
await gotoWithStablePrefs(page, '/');
|
||||
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Preferences persist across page reload', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
await resetPreferences(page);
|
||||
|
||||
// Given the user changes default note mode to Markdown
|
||||
await gotoWithStablePrefs(page, '/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 page is reloaded (block sync to prevent parallel-test interference)
|
||||
await page.route('**/api/preferences', (route) => route.abort(), { times: 1 });
|
||||
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
|
||||
}) => {
|
||||
// Use a unique name to avoid strict-mode violations from parallel test runs
|
||||
const keyName = `Test Key ${Date.now()}`;
|
||||
|
||||
// When the user navigates to the MCP settings and creates an API key
|
||||
await page.goto('/settings/mcp');
|
||||
await page.getByTestId('api-key-name-input').fill('Settings Test Key');
|
||||
await page.getByTestId('api-key-name-input').fill(keyName);
|
||||
await page.getByTestId('create-api-key-btn').click();
|
||||
|
||||
// Then the key is shown once for copying
|
||||
@@ -15,20 +146,22 @@ test.describe('Settings — API Key Management', () => {
|
||||
expect(keyValue).toMatch(/^crumbs_/);
|
||||
|
||||
// And it appears in the keys list
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Settings Test Key' });
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: keyName });
|
||||
await expect(keyItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Revoked API key disappears from the keys list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
const keyName = `Revoke Key ${Date.now()}`;
|
||||
|
||||
// Given an API key exists
|
||||
await page.goto('/settings/mcp');
|
||||
await page.getByTestId('api-key-name-input').fill('Revoke Me');
|
||||
await page.getByTestId('api-key-name-input').fill(keyName);
|
||||
await page.getByTestId('create-api-key-btn').click();
|
||||
await expect(page.getByTestId('created-key-display')).toBeVisible();
|
||||
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Revoke Me' });
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: keyName });
|
||||
await expect(keyItem).toBeVisible();
|
||||
|
||||
// When the user revokes the key
|
||||
@@ -39,33 +172,34 @@ test.describe('Settings — API Key Management', () => {
|
||||
await expect(keyItem).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Settings page is accessible from sidebar', async ({
|
||||
test('Scenario: Sidebar settings link opens the preferences page', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user is on the main page
|
||||
// When the user clicks the Settings link in the sidebar
|
||||
// When the user navigates to settings from the sidebar
|
||||
await page.getByTestId('settings-link').click();
|
||||
|
||||
// Then the settings page is shown
|
||||
await expect(page).toHaveURL('/settings');
|
||||
// Then the preferences page is displayed
|
||||
await expect(page).toHaveURL('/settings/preferences');
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Settings subpages are navigable', async ({
|
||||
test('Scenario: Settings tabs navigate between subpages', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// 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 selects the API tab
|
||||
await page.getByRole('link', { name: 'API' }).click();
|
||||
|
||||
// Then the MCP settings page is shown
|
||||
// Then the API settings are displayed
|
||||
await expect(page).toHaveURL('/settings/mcp');
|
||||
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
|
||||
|
||||
// When the user clicks back to Profile
|
||||
// When the user selects the Profile tab
|
||||
await page.getByRole('link', { name: 'Profile' }).click();
|
||||
await expect(page).toHaveURL('/settings');
|
||||
|
||||
// Then the profile settings are displayed
|
||||
await expect(page).toHaveURL('/settings/profile');
|
||||
});
|
||||
});
|
||||
|
||||
+114
-191
@@ -1,233 +1,156 @@
|
||||
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('Note Sharing', () => {
|
||||
test('Scenario: Owner shares a note via UI and collaborator sees it', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a note titled "Share Test" exists
|
||||
await page.request.post('/api/notes', {
|
||||
data: { title: 'Share Test', content: 'Shared content' }
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(noteCard(page, 'Share Test')).toBeVisible();
|
||||
|
||||
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"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Shared Note');
|
||||
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();
|
||||
|
||||
// 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/**']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -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.16.0</span>
|
||||
<span class="footer-version">v0.19.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user