50d2348b36
- tests/MERGE_CHECKLIST.md → tests/ALL_TESTS.md (git rename, updated header + index of sub-files) - tests/backend_tests.md — §1–9, §18 (auth, users, admin, groups, appearance, service health, plugins, AI/doc settings, infra/security) - tests/frontend_tests.md — §19 (UI & routing) - tests/doc-service_tests.md — §10–16 (upload/processing, list/filtering, slide-over, sharing, categories, bulk actions, watch directory) - tests/ai-service_tests.md — §17 (AI queue & providers) - CLAUDE.md: updated merge checklist section, file tree, and self-update checkpoint with mandatory test-file update rule - settings.local.json: added docker inspect/ps, curl, lsof, git merge/branch/log/diff/status/config/mv permissions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
26 KiB
26 KiB
ALL_TESTS — Full Test Suite
Complete test suite covering all 19 feature areas. Run tests relevant to the changed area before merging any feature branch into main. Service-specific subsets live in separate files:
tests/backend_tests.md— §1–9, §18 (auth, users, admin, groups, appearance, service health, plugins, AI/doc settings, infra/security)tests/frontend_tests.md— §19 (UI & routing)tests/doc-service_tests.md— §10–16 (upload/processing, list/filtering, slide-over, sharing, categories, bulk actions, watch directory)tests/ai-service_tests.md— §17 (AI queue & providers)
Every test describes the exact UI action or API call to perform and the expected outcome.
Test environment: Feature stack at http://localhost:$PORT (see CLAUDE.md §Feature branch workflow).
Admin credentials: any superuser account created during stack setup.
Regular user credentials: a second non-admin account for permission boundary tests.
Legend
| Symbol | Meaning |
|---|---|
| ✅ | Pass |
| ❌ | Fail |
| — | N/A for this change |
Mark each row before opening the PR.
1. Authentication
| # | Test | Steps | Expected |
|---|---|---|---|
| 1.1 | Register new account | POST /api/auth/register with valid email + strong password |
201; user row created; login works immediately |
| 1.2 | Password policy — too short | Register with 7-char password | 422 with validation error |
| 1.3 | Password policy — no uppercase | Register with all-lowercase password | 422 with validation error |
| 1.4 | Password policy — no special char | Register without special character | 422 with validation error |
| 1.5 | Password policy — common word | Register with password containing "password" | 422 with validation error |
| 1.6 | Duplicate email | Register with an already-used email | 400 |
| 1.7 | Login — valid credentials | POST /api/auth/login with correct email + password |
200; access_token returned |
| 1.8 | Login — wrong password | POST /api/auth/login with wrong password |
401 |
| 1.9 | Login — inactive account | Admin deactivates user; attempt login | 401 |
| 1.10 | JWT expiry respected | Manually craft token with exp in the past; call any protected route |
401 |
| 1.11 | Logout clears session | Click Logout in UI; try navigating to / |
Redirected to /login |
| 1.12 | Unauthenticated redirect | Open / without a token in localStorage |
Redirected to /login |
2. User — Profile & Preferences
| # | Test | Steps | Expected |
|---|---|---|---|
| 2.1 | Fetch own profile | GET /api/profile/me |
200; profile auto-created if first request |
| 2.2 | Update profile fields | PUT /api/profile/me with full_name, phone, position, address, date_of_birth |
200; fields persisted; visible on /profile page |
| 2.3 | Invalid phone format | PUT /api/profile/me with letters in phone field |
422 |
| 2.4 | Future date of birth | PUT /api/profile/me with DOB = tomorrow |
422 |
| 2.5 | DOB before 1900 | PUT /api/profile/me with DOB = 1899-12-31 |
422 |
| 2.6 | Fetch dashboard preferences | GET /api/users/me/preferences |
200; app_ids list |
| 2.7 | Pin an app | Dashboard → pencil button → press + on a card → save |
Card appears in pinned grid on next load |
| 2.8 | Unpin an app | Dashboard → pencil button → press − on a pinned card → save |
Card removed from pinned grid |
| 2.9 | Pin limit (50) | PATCH /api/users/me/preferences with 51 app IDs |
422 |
| 2.10 | Color mode — user pref | User sets mode to "dark"; reload page | Dark theme applied; preference persists across sessions |
| 2.11 | Color mode — system fallback | User has NULL color_mode; admin default_mode = "light" | Light theme applied |
3. Admin — Users
| # | Test | Steps | Expected |
|---|---|---|---|
| 3.1 | List all users | Admin → /admin/users |
All registered users shown in table |
| 3.2 | Create user | Admin clicks "Create user"; fills form | 201; new user appears in list; can log in |
| 3.3 | Toggle user active | Admin clicks toggle on active user | User deactivated; login returns 401 |
| 3.4 | Delete user | Admin deletes a user | 204; user no longer in list; their documents remain (orphaned) |
| 3.5 | Non-admin access | Regular user navigates to /admin/users |
Redirected to /login |
| 3.6 | Admin 404 semantics | Regular user calls GET /api/admin/users via curl |
404 (not 403) |
4. Admin — Groups
| # | Test | Steps | Expected |
|---|---|---|---|
| 4.1 | List groups | Admin → /admin/groups |
All groups shown with member count |
| 4.2 | Create group | Fill name + description → submit | Group appears in list; {group_name}-admin bootstrap group also exists (auto-created on service start) |
| 4.3 | Edit group | Click edit on group → change name → save | Name updated |
| 4.4 | Delete group | Delete group | 204; group gone; memberships cascade-deleted |
| 4.5 | Add member | Open group → search user → add | 204; user appears in member list |
| 4.6 | Remove member | Click remove on a member | User removed from group |
| 4.7 | Duplicate group name | Create group with name that already exists | 400 / validation error shown |
| 4.8 | Non-admin access | Regular user calls GET /api/admin/groups |
404 |
| 4.9 | Set group admin role | Admin → group detail → tick "Group admin" checkbox on a member → save | PATCH /api/admin/groups/{id}/members/{user_id}/admin with {"is_group_admin": true} returns 200; badge shown in member list |
| 4.10 | Unset group admin role | Admin unticks "Group admin" on an existing group admin member | Returns 200; badge removed; user loses group-admin privileges |
5. Admin — Appearance
| # | Test | Steps | Expected |
|---|---|---|---|
| 5.1 | List themes | Admin → /admin/appearance |
Built-in themes + any custom themes shown |
| 5.2 | Switch active theme | Select a different theme → save | All users see the new theme on next load |
| 5.3 | Create custom theme | Admin → create theme. Required fields: id (slug), label, light (CSS vars object), dark (CSS vars object) |
201; theme appears in selector; can be activated |
| 5.4 | Edit custom theme | Admin edits colour values on a custom theme | Colours update live after activation |
| 5.5 | Delete custom theme | Admin deletes a custom theme | 204; theme gone from selector; active theme reverts to default |
| 5.6 | Set default mode | PATCH /api/settings/appearance with {"theme": "<id>", "default_mode": "dark"} (both fields required) |
200; new users without a personal preference see dark mode |
6. Service Health & Dashboard
| # | Test | Steps | Expected |
|---|---|---|---|
| 6.1 | Services endpoint | GET /api/services (authenticated) |
Returns health status for doc-service and ai-service |
| 6.2 | Healthy service card | Both services running → /apps page. API response uses healthy: true (boolean), not status: "healthy" |
Cards show "Available" badge; clicking navigates to the app |
| 6.3 | Unhealthy service card | Stop doc-service container → wait 30s → /apps |
Doc-service card dimmed, "Unavailable", not clickable |
| 6.4 | Service recovery | Restart stopped container → wait 30s | Card returns to "Available" |
| 6.5 | Dashboard pinned cards | Pin a service → go to / |
Pinned card appears in home grid |
| 6.6 | Customize mode | Click pencil on dashboard → toggle cards | Pinned list updates after save |
7. Plugin System
| # | Test | Steps | Expected |
|---|---|---|---|
| 7.1 | List plugins | GET /api/plugins (authenticated) |
Returns accessible plugins for current user |
| 7.2 | Superuser sees all plugins | Log in as admin → GET /api/plugins |
All registered service plugins returned |
| 7.3 | Group member sees plugin | Add user to doc-service-admin group → GET /api/plugins |
doc-service plugin returned |
| 7.4 | Unpermitted user hidden | Regular user not in any admin group → GET /api/plugins |
Empty list (plugins hidden, not 403) |
| 7.5 | Manifest fetch | GET /api/plugins/doc-service/manifest as permitted user |
JSON Schema + access rules returned |
| 7.6 | Settings read | GET /api/plugins/doc-service/settings |
Current doc-service plugin settings returned |
| 7.7 | Settings write | PATCH /api/plugins/doc-service/settings with valid payload |
200; setting persisted to volume |
| 7.8 | Unpermitted settings access | Regular user GET /api/plugins/doc-service/settings |
404 |
8. AI Service Settings
| # | Test | Steps | Expected |
|---|---|---|---|
| 8.1 | Read AI config | Admin (or ai-service-admin member) → GET /api/settings/ai |
Config returned; API keys masked |
| 8.2 | Update provider | PATCH /api/settings/ai with provider = "anthropic" + valid key |
200; config persisted |
| 8.3 | Test connection | POST /api/settings/ai/test with valid config |
200; success response from provider |
| 8.4 | Test connection — bad key | POST /api/settings/ai/test with wrong API key |
502 or error detail |
| 8.5 | Read system prompts | GET /api/settings/system-prompts |
All registered service prompts returned |
| 8.6 | Update system prompt | PATCH /api/settings/system-prompts/doc-service with new prompt text |
200; doc-service picks up new prompt within 30s |
| 8.7 | Non-admin access | Regular user calls any /api/settings/ai endpoint |
404 |
| 8.8 | ai-service-admin delegation |
Non-superuser added to ai-service-admin group → accesses AI settings page |
Page loads; can read and write settings |
9. Document Service Settings
| # | Test | Steps | Expected |
|---|---|---|---|
| 9.1 | Read upload limits | GET /api/settings/documents/limits (admin or doc-service-admin) |
max_pdf_bytes returned |
| 9.2 | Update upload limit | PATCH /api/settings/documents/limits with new value |
200; upload of oversized PDF now rejected with 413 |
| 9.3 | Non-admin access | Regular user calls GET /api/settings/documents/limits |
404 |
| 9.4 | Settings page loads | Admin navigates to /apps/documents/settings |
Upload limits section + watch directory config visible |
| 9.5 | doc-service-admin delegation |
Non-superuser added to doc-service-admin → navigates to settings page |
Page loads; settings editable |
10. Document Upload & Processing
| # | Test | Steps | Expected |
|---|---|---|---|
| 10.1 | Upload valid PDF | Drag-and-drop or file picker → select a PDF under the size limit | 202; document row appears with status=pending; transitions to done |
| 10.2 | Upload oversized PDF | Upload a PDF exceeding max_pdf_bytes |
413; error shown; no row created |
| 10.3 | Upload non-PDF | Upload a .docx or .jpg |
415; error shown |
| 10.4 | Multi-file upload | Select 3 PDFs at once | All 3 appear in upload queue panel; each processes independently |
| 10.5 | Upload queue panel | During upload → check bottom-right panel | Per-file status indicator; "Review →" link after each completes |
| 10.6 | Drag-and-drop overlay | Drag file over the documents page | Full-page overlay appears; drop uploads file |
| 10.7 | Processing status poll | Upload a large PDF | Table row auto-updates every 3s until status = done or failed |
| 10.8 | AI extraction result | Open slide-over for a done document |
title, document_type, tags, extracted_data fields populated |
| 10.9 | Failed extraction | AI service down → upload PDF | Status = failed; error_message shown in slide-over |
| 10.10 | Re-analyse | Click "Re-analyse" in slide-over | 202; status resets to pending; re-processes through AI |
11. Document List & Filtering
| # | Test | Steps | Expected |
|---|---|---|---|
| 11.1 | Default list | Navigate to /apps/documents |
Own documents shown, newest first, 20 per page |
| 11.2 | Search | Type in search box (debounced 400ms) | Results filtered by title / filename / tags / type |
| 11.3 | Filter by status | Add filter chip → Status → "done" | Only completed docs shown |
| 11.4 | Filter by type | Add filter chip → Document type → "invoice" | Only invoices shown |
| 11.5 | Filter by category | Add filter chip → Category → pick one | Only docs in that category shown |
| 11.6 | Remove filter chip | Click × on a chip | Filter removed; full list restored |
| 11.7 | Sort by column | Click "Date" column header | List re-ordered; chevron indicates direction; click again reverses |
| 11.8 | Pagination | Upload > 20 docs → scroll to bottom | Page controls appear; page 2 loads next 20 |
| 11.9 | "Mine" view | Click "Mine" in SourcePanel | Only own (uploaded) documents shown |
| 11.10 | "Shared with me" view | Click "Shared with me" | Docs shared by others via groups; own docs excluded |
| 11.11 | Category filter via SourcePanel | Click a category in the left tree | Table filtered to that category's documents |
| 11.12 | URL state preserved | Apply filters → copy URL → open in new tab | Same filters applied |
12. Document Detail — Slide-over
| # | Test | Steps | Expected |
|---|---|---|---|
| 12.1 | Open slide-over | Click any document row | 480px right panel slides in; metadata loaded |
| 12.2 | Inline title edit | Click pencil icon next to title → type new title → confirm | Title saved; updated in table row |
| 12.3 | Change document type | Click a type chip (Invoice, Receipt, etc.) | Type updated immediately |
| 12.4 | Edit tags | Click into tag area → type a tag → press Enter → remove a tag with × | Tags saved correctly |
| 12.5 | Assign category | Categories combobox → search → select | Category badge appears on document; table row updates |
| 12.6 | Remove category | Click × on an assigned category badge | Category removed from document |
| 12.7 | AI category suggestions | Slide-over shows "Suggested categories" | "Assign" and "Create & Assign" buttons present; clicking assigns |
| 12.8 | Confirm folder suggestion | "Confirm" button next to suggested_folder | Category created (if needed) and assigned; suggested_folder cleared |
| 12.9 | Reject folder suggestion | "Reject" button next to suggested_folder | suggested_folder cleared; no category created |
| 12.10 | Confirm filename suggestion | "Confirm" button next to suggested_filename | title updated to suggested value; suggested_filename cleared |
| 12.11 | Reject filename suggestion | "Reject" button next to suggested_filename | suggested_filename cleared; title unchanged |
| 12.12 | Extracted data section | Open slide-over on done doc |
Key-value table of AI-extracted fields (vendor, amounts, dates, etc.) |
| 12.13 | Raw text section | Expand raw text collapse | First ~500k chars of extracted PDF text shown |
| 12.14 | Download | Click "Download" | Browser downloads the original PDF file |
| 12.15 | View in new tab | Click "View" | PDF opens in new browser tab; URL auto-revokes after 60s |
| 12.16 | Delete — owner | Click "Delete" → confirm dialog | Document and file removed; table row gone |
| 12.16b | Delete — admin | Admin opens any doc (not their own) → Delete → confirm | Document deleted; 204 returned |
| 12.16c | Delete — can_delete share | Group member whose share has can_delete=true → Delete |
204; document removed; viewer_can_delete was true in DocumentOut |
| 12.16d | Delete — group admin | User is group admin for a group the doc is shared with; no explicit can_delete flag → Delete |
204; group admin always has delete rights for docs shared with their group |
| 12.16e | Delete — watch document, admin only | Watch-ingested doc (source=watch); regular user → Delete |
403 (not owner); admin can delete it |
| 12.17 | Non-owner cannot edit | Recipient of shared doc opens slide-over (no can_delete, not group admin) | Edit controls (type, tags, title, delete) absent; download available |
13. Document Sharing
| # | Test | Steps | Expected |
|---|---|---|---|
| 13.1 | Share from slide-over | Owner opens sharing section → selects a group from combobox → shares | Group appears in shares list; share_count in table row increments |
| 13.2 | Only user's own groups shown | Open group picker in share section | Only groups the current user belongs to are listed |
| 13.3 | Recipient sees shared doc | Log in as group member → "Shared with me" view | Shared document appears with primary accent border |
| 13.4 | Recipient download | Recipient clicks Download on shared doc | PDF downloaded successfully |
| 13.4b | Non-owner calls GET /documents/{id}/shares |
Regular user on a doc they don't own | 404 (doc-service hides existence, consistent with admin 404 semantics — not 403) |
| 13.5 | Recipient cannot delete | Recipient opens slide-over | Delete button absent |
| 13.6 | Recipient cannot re-share | Recipient opens sharing section | Share controls absent |
| 13.7 | Remove share | Owner clicks remove on a group share | Group removed; share_count decrements; recipient no longer sees doc |
| 13.8 | Bulk share | Select multiple rows → bulk share → pick group | All selected docs shared with that group |
| 13.9 | Share count indicator | Document shared with 2 groups | Users icon in table row shows "2" |
| 13.10 | Share with non-member group | POST /api/documents/{id}/shares with group not in X-User-Groups |
403 / validation error |
| 13.11 | Share with can_delete enabled | Owner opens sharing section → tick "Allow group members to delete" → share | can_delete=true stored; trash icon badge appears next to group name in shares list |
| 13.12 | Share without can_delete (default) | Owner shares without ticking delete checkbox | can_delete=false; recipient sees the doc but Delete button absent in slide-over |
| 13.13 | can_delete shown in shares list | Share with can_delete=true → inspect shares list in slide-over | Trash2 icon rendered beside the group name; tooltip "Group members can delete this document" |
| 13.14 | viewer_can_delete in document list | Share with can_delete=true; log in as group member → GET /api/documents |
viewer_can_delete=true in the recipient's list response for that doc |
14. Categories
| # | Test | Steps | Expected |
|---|---|---|---|
| 14.1 | Create category | SourcePanel → "New category" form → submit | Category appears in tree |
| 14.2 | Rename category | Manage categories dialog → edit → save | New name reflected everywhere |
| 14.3 | Delete category | Delete category with documents assigned | 204; documents remain; category assignment removed |
| 14.4 | Category search | More than 4 categories → type in search field | Tree filtered in real time |
| 14.5 | Manage categories dialog | Click "Manage categories" | Modal shows categories grouped by scope (Personal / Group / System) with lock icons on group and system categories |
| 14.6 | New category triggers re-analysis | Create category with name similar to AI suggestion | Background re-analysis triggered (check backend logs) |
| 14.7 | Create personal category | SourcePanel → "New category" → no group selected → submit valid PascalCase name | Created with scope=personal; visible only to owner |
| 14.8 | Create group-scoped category | SourcePanel → "New category" → select a group → submit | Created with scope=group; visible to all members of that group |
| 14.9 | Group category visible to group members | Log in as another group member | Group category appears in their category list and SourcePanel |
| 14.10 | Non-member cannot see group category | Log in as user not in the group | Group category absent from list |
| 14.11 | Only group admin can rename group category | Regular group member → rename group category | 403; group admin can rename it successfully |
| 14.12 | Only group admin can delete group category | Regular group member → delete group category | 403; group admin can delete it |
| 14.13 | System categories read-only for non-admin | Regular user → Manage categories → rename/delete a system category | 403; lock icon shown; action blocked in UI |
| 14.14 | Admin can manage system categories | Superuser → rename or delete a system category | Succeeds; ManageCategoriesDialog shows edit/delete controls for system rows |
| 14.15 | PascalCase naming enforced — invalid | Create category named my-invoices or Invoice Reports |
422 with message explaining PascalCase-with-dashes format |
| 14.16 | PascalCase naming enforced — valid | Create category named Vendor-Invoices |
201; category created successfully |
| 14.17 | SourcePanel scope sections | Categories exist for all three scopes | SourcePanel tree shows "Mine", per-group, and "System" sections separately |
15. Bulk Actions
| # | Test | Steps | Expected |
|---|---|---|---|
| 15.1 | Select rows | Tick checkboxes on multiple rows | Floating bulk actions bar appears at bottom |
| 15.2 | Bulk share | Select docs → Share with group → confirm | All selected docs shared; confirmation |
| 15.3 | Bulk delete | Select docs → Delete → confirm dialog | All selected docs deleted; bar disappears |
| 15.4 | Clear selection | Click "Clear" in bulk bar | All checkboxes deselected; bar hides |
| 15.5 | Bulk bar — "Mine" view only | Switch to "Shared with me" view | Bulk actions bar not shown (no edit rights for shared docs) |
16. Watch Directory
| # | Test | Steps | Expected |
|---|---|---|---|
| 16.1 | Enable watch | Doc settings page → toggle watch_enabled on → save |
File watcher starts; backend logs confirm |
| 16.2 | Ingest new file | Drop a PDF into the bind-mounted watch directory | Document appears in "All Documents" view with source=watch |
| 16.3 | Sub-folder to category | Place PDF in watch/invoices/ |
Document auto-assigned to "invoices" category |
| 16.4 | Startup scan | Restart doc-service with PDFs already in watch dir | Pre-existing PDFs ingested (idempotent — no duplicates) |
| 16.5 | AI folder suggestion | ai_folder_suggestion enabled → ingest file |
suggested_folder populated; confirm/reject buttons visible in slide-over |
| 16.6 | AI rename suggestion | ai_rename_suggestion enabled → ingest file |
suggested_filename populated; confirm/reject buttons visible |
| 16.7 | No-remove policy | Delete PDF from watch dir | Document record remains in DB |
| 16.8 | Disable watch | Toggle watch_enabled off → save |
Watcher stops; new files dropped are not ingested |
| 16.9 | Watch docs visible to all users | Log in as any authenticated user | Watch-ingested docs (user_id = "watch") appear in "All Documents" |
17. AI Service — Queue & Providers
| # | Test | Steps | Expected |
|---|---|---|---|
| 17.1 | Health check | GET /health on ai-service (via backend services endpoint) |
{"status": "ok"} |
| 17.2 | Provider health | GET /health/provider |
Active provider name, model, configured=true |
| 17.3 | Unconfigured provider | Set provider to "anthropic" with empty API key → test connection | 503 or 502 with clear error |
| 17.4 | Sync chat | POST /chat with valid messages array |
Response returned synchronously |
| 17.5 | Queue — async job | POST /queue/jobs |
job_id returned immediately |
| 17.6 | Queue — poll job | GET /queue/jobs/{id} after enqueue |
Returns status (pending → done) and result |
| 17.7 | Queue — cancel job | DELETE /queue/jobs/{id} before processing |
Job removed; status = cancelled |
| 17.8 | Queue pause | POST /queue/pause |
204; current job finishes; no new jobs picked up |
| 17.9 | Queue resume | POST /queue/resume after pause |
204; worker resumes; pending jobs process |
| 17.10 | Priority ordering | Enqueue LOW then HIGH job | HIGH job processed first |
| 17.11 | Provider timeout | POST /chat when provider is unreachable |
504 returned after timeout |
18. Infrastructure & Security
| # | Test | Steps | Expected |
|---|---|---|---|
| 18.1 | Non-root containers | docker inspect <container> --format '{{.Config.User}}' for each service |
Returns 1001 (or 70 for db) |
| 18.2 | No host ports in prod | docker compose up --build -d → docker ps |
Only port 80 (frontend) exposed; no 8000/8001/8010/5432 |
| 18.3 | backend-net isolation | curl http://localhost:8000 from host in prod |
Connection refused |
| 18.4 | Pre-commit hook runs | Stage a file with eval("x") → git commit |
Commit blocked; security_check.py output shown |
| 18.5 | Pre-commit hook — clean code | Normal commit | Hook passes; commit succeeds |
| 18.6 | JWT algorithm none rejected | Craft token with "alg": "none" → call protected route |
401 |
| 18.7 | XSS — input sanitation | Enter <script>alert(1)</script> in title/name fields |
Value stored as plain text; not executed in UI |
| 18.8 | SQL injection attempt | Pass '; DROP TABLE documents; -- as search param |
200 with empty results; no DB error |
| 18.9 | CORS | curl -H "Origin: http://evil.com" http://localhost/api/users/me |
Request blocked or access-control-allow-origin not set for that origin |
| 18.10 | Config volume persistence | Restart all containers | AI provider config + doc limits survive restart |
| 18.11 | Migration auto-apply | Start fresh stack | Both alembic upgrade head chains run without error; all tables created |
19. Frontend — UI & Routing
| # | Test | Steps | Expected |
|---|---|---|---|
| 19.1 | PrivateRoute redirect | Open any protected route without token | Redirected to /login |
| 19.2 | AdminRoute redirect | Log in as non-admin → navigate to /admin |
Redirected to /login |
| 19.3 | ServiceAdminRoute | Non-admin, non-group-member → navigate to /apps/documents/settings |
Redirected (access denied) |
| 19.4 | Sidebar collapse | Click collapse button | Sidebar shrinks to icon-only; expand restores labels |
| 19.5 | Apps accordion | Click "Apps" in sidebar | Expands to show "Documents" NavLink |
| 19.6 | SourcePanel visibility | Navigate to /apps then /apps/documents |
SourcePanel only visible on /apps/documents route |
| 19.7 | Theme toggle | Click sun/moon button | Mode switches; persists on reload |
| 19.8 | Unknown route | Navigate to /does-not-exist |
Redirected to / |
| 19.9 | TanStack Query cache | Navigate away from docs → back | List loads from cache instantly; background refetch runs |
| 19.10 | 30s service poll | Leave /apps open for 30s |
GET /api/services fires again in network tab |
| 19.11 | Three-dots menu not clipped | Scroll document table → open three-dot actions on any row | Dropdown renders above the table's overflow-hidden container; not cut off |