feat(phase-4): complete UX redesign — FileManagerView, FolderTreeItem, test suite, and all Phase 4 fixes

Adds the unified file manager view (Windows Explorer-style), collapsible
folder tree sidebar item, full vitest test suite (55 tests, 4 files), and
commits all Phase 4 backend/frontend fixes that were staged but uncommitted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-28 17:10:52 +02:00
parent 654622d358
commit 87a32b7ee8
25 changed files with 2534 additions and 163 deletions
+6 -6
View File
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: milestone milestone_name: milestone
current_phase: 4 current_phase: 4
status: planned status: completed
last_updated: "2026-05-25T16:00:00.000Z" last_updated: "2026-05-28T14:59:51.958Z"
progress: progress:
total_phases: 5 total_phases: 5
completed_phases: 3 completed_phases: 4
total_plans: 33 total_plans: 24
completed_plans: 15 completed_plans: 24
percent: 60 percent: 80
--- ---
# Project State # Project State
+5 -1
View File
@@ -9,7 +9,11 @@
"plan_check": true, "plan_check": true,
"verifier": true, "verifier": true,
"nyquist_validation": true, "nyquist_validation": true,
"auto_advance": false "auto_advance": false,
"test_gate": true,
"security_check": true,
"bugfix_max_lines": 50,
"require_root_cause_fix": true
}, },
"ship": { "ship": {
"pr_body_sections": [ "pr_body_sections": [
@@ -1,14 +1,15 @@
--- ---
status: testing status: complete
phase: 03-document-migration-multi-user-isolation phase: 03-document-migration-multi-user-isolation
source: 03-01-SUMMARY.md, 03-02-SUMMARY.md, 03-03-SUMMARY.md, 03-04-SUMMARY.md, 03-05-SUMMARY.md source: 03-01-SUMMARY.md, 03-02-SUMMARY.md, 03-03-SUMMARY.md, 03-04-SUMMARY.md, 03-05-SUMMARY.md
started: 2026-05-24T00:00:00Z started: 2026-05-24T00:00:00Z
updated: 2026-05-24T00:00:00Z updated: 2026-05-25T00:00:00Z
completed: 2026-05-25T00:00:00Z
--- ---
## Current Test ## Current Test
UAT-3 — QuotaBar visual in sidebar (needs browser confirmation) All tests complete — Phase 3 UAT passed.
## Tests ## Tests
@@ -23,7 +24,8 @@ reported: "User confirmed upload works after fixes."
### 3. QuotaBar displays in sidebar ### 3. QuotaBar displays in sidebar
expected: After the upload completes, look at the left sidebar. A quota bar widget is visible below the navigation links. It shows used/total storage (e.g. "1.2 MB / 100 MB") with an indigo-colored fill bar. No error state or broken layout. expected: After the upload completes, look at the left sidebar. A quota bar widget is visible below the navigation links. It shows used/total storage (e.g. "1.2 MB / 100 MB") with an indigo-colored fill bar. No error state or broken layout.
result: [pending] result: pass
reported: "QuotaBar visible in sidebar with indigo fill bar. Confirmed by user 2026-05-25."
### 4. Quota rejection error block ### 4. Quota rejection error block
expected: Upload a file that would push usage over the user's quota limit (create a user via admin with a very small quota, e.g. 1 byte, or use an account already near-full). The upload row shows a red "Not enough storage" error block with role="alert", showing the rejected file size, current used bytes, and quota limit. A "Manage storage →" link appears. The quota bar does NOT increase past the limit. expected: Upload a file that would push usage over the user's quota limit (create a user via admin with a very small quota, e.g. 1 byte, or use an account already near-full). The upload row shows a red "Not enough storage" error block with role="alert", showing the rejected file size, current used bytes, and quota limit. A "Manage storage →" link appears. The quota bar does NOT increase past the limit.
@@ -62,9 +64,9 @@ reported: "document_tasks.py _run() resolves ai_provider from user.ai_provider w
## Summary ## Summary
total: 10 total: 10
passed: 9 passed: 10
issues: 0 issues: 0
pending: 1 pending: 0
skipped: 0 skipped: 0
blocked: 0 blocked: 0
@@ -0,0 +1,158 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: "05"
subsystem: backend-api
tags: [streaming-proxy, content-delivery, preferences, pdf, range-requests, doc-02, doc-01]
dependency_graph:
requires:
- 04-02 # pdf_open_mode migration 0004 adds users.pdf_open_mode column
- 04-04 # Share model with recipient_id for access control
provides:
- "GET /api/documents/{id}/content — streaming proxy from MinIO"
- "GET /api/auth/me/preferences — read pdf_open_mode"
- "PATCH /api/auth/me/preferences — update pdf_open_mode"
affects:
- 04-09 # frontend uses content URL + preferences PATCH for PDF display
tech_stack:
added: []
patterns:
- "StreamingResponse with iter([bytes]) for zero-copy streaming"
- "_parse_range() module-level helper for RFC 7233 byte range parsing"
- "Literal['in_app', 'new_tab'] Pydantic field for allowlist enforcement (T-04-05-05)"
- "get_regular_user dep blocks admin access to content proxy (T-04-05-01)"
key_files:
created: []
modified:
- path: backend/api/documents.py
role: "Added _parse_range() + stream_document_content endpoint"
- path: backend/api/auth.py
role: "Added PreferencesUpdate model + GET/PATCH /me/preferences endpoints"
- path: backend/db/models.py
role: "Added pdf_open_mode column to User ORM model"
- path: backend/tests/test_documents.py
role: "Replaced xfail stubs with 8 real streaming proxy tests"
- path: backend/tests/test_auth_api.py
role: "Added 7 preferences endpoint tests"
decisions:
- "Use get_regular_user (not get_current_user) for content proxy: admin role blocked at dep level (T-04-05-01)"
- "Fetch bytes via get_object() directly — presigned_get_url() forbidden in proxy handler (T-04-05-02)"
- "Access check inline in handler body (not helper function) for test mocking simplicity"
- "HTTP_416_RANGE_NOT_SATISFIABLE used instead of deprecated HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE"
- "pdf_open_mode added to User ORM model (migration 0004 already added the DB column)"
- "GET /me/preferences uses AttributeError guard for env without migration run"
metrics:
duration: "~15 minutes"
completed: "2026-05-25"
tasks_completed: 2
tasks_total: 2
files_modified: 5
---
# Phase 04 Plan 05: Document Streaming Proxy + PDF Preferences Summary
**One-liner:** MinIO streaming proxy for document content with Range header support (DOC-02) and pdf_open_mode user preference endpoint (DOC-01) backed by Literal validation.
## What Was Built
### Task 1: GET /api/documents/{id}/content (DOC-02)
Added a streaming proxy endpoint to `backend/api/documents.py`:
- **`_parse_range(range_header, file_size)`** module-level helper that parses RFC 7233 `bytes=X-Y` syntax, handles open-ended ranges (`bytes=X-` and `bytes=-Y`), and raises HTTP 416 on any invalid or out-of-bounds range
- **`stream_document_content`** endpoint at `GET /api/documents/{id}/content`:
- Uses `get_regular_user` dep — admin role → 403 (T-04-05-01, CRITICAL)
- Parses doc_id as UUID → 404 on ValueError
- Loads Document via session.get → 404 if None
- Access: `doc.user_id == current_user.id` OR `Share.recipient_id == current_user.id`; neither → 404 (T-04-05-04)
- Fetches bytes via `get_storage_backend().get_object(doc.object_key)` — no presigned URL (T-04-05-02)
- Returns `StreamingResponse` with `content-type`, `content-disposition: inline`, `accept-ranges: bytes`, `content-length`
- Range header present → 206 with `content-range: bytes {start}-{end}/{total}` (T-04-05-03)
- No Range → 200
Also added `pdf_open_mode` column to `User` ORM model (migration 0004 already added the DB column).
### Task 2: Preferences Endpoints (DOC-01)
Added to `backend/api/auth.py`:
- **`PreferencesUpdate`** Pydantic model with `pdf_open_mode: Literal["in_app", "new_tab"]`
- **`GET /api/auth/me/preferences`** — returns `{"pdf_open_mode": ...}` using `get_current_user` (both roles)
- **`PATCH /api/auth/me/preferences`** — validates via Literal, updates `current_user.pdf_open_mode`, commits, returns updated value
## Commits
| Hash | Message |
|------|---------|
| `8e6cb6e` | test(phase-4-05): add failing tests for document streaming proxy (DOC-02) — RED phase |
| `f868a4e` | feat(phase-4-05): document streaming proxy GET /api/documents/{id}/content (DOC-02) — GREEN phase |
| `2a0df32` | feat(phase-4-05): PATCH /api/auth/me/preferences for pdf_open_mode (DOC-01) |
## Test Results
**Before this plan:** 85 passed, 10 xfailed (document tests only had xfail stubs)
**After this plan:** 137 passed, 35 xfailed — all new tests green
| Test | Result |
|------|--------|
| test_content_stream_200 | PASSED |
| test_content_stream_206_range | PASSED |
| test_content_stream_admin_403 | PASSED |
| test_content_stream_no_presigned_url | PASSED |
| test_content_stream_share_recipient_200 | PASSED |
| test_content_stream_not_found | PASSED |
| test_content_stream_invalid_id | PASSED |
| test_parse_range_416 | PASSED |
| test_get_preferences_default | PASSED |
| test_patch_preferences_in_app | PASSED |
| test_patch_preferences_new_tab | PASSED |
| test_patch_preferences_invalid_value | PASSED |
| test_patch_preferences_persists | PASSED |
| test_preferences_requires_auth | PASSED |
| test_patch_preferences_requires_auth | PASSED |
Pre-existing failure: `test_extract_docx` (missing `python-docx` module in local env — not introduced by this plan).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Deprecated HTTP_416 status constant**
- **Found during:** Task 1 implementation
- **Issue:** `HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE` is deprecated in the installed FastAPI version; `HTTP_416_RANGE_NOT_SATISFIABLE` is the current constant
- **Fix:** Used `HTTP_416_RANGE_NOT_SATISFIABLE` throughout
- **Files modified:** `backend/api/documents.py`
- **Commit:** f868a4e
**2. [Rule 2 - Missing functionality] pdf_open_mode absent from User ORM model**
- **Found during:** Task 2 — plan noted "migration 0004 adds column" but the ORM `User` class in `models.py` did not declare `pdf_open_mode`
- **Fix:** Added `pdf_open_mode: Mapped[str]` with `server_default="in_app"` to the User class
- **Files modified:** `backend/db/models.py`
- **Commit:** f868a4e
## Security Invariants Verified
| Threat ID | Status |
|-----------|--------|
| T-04-05-01: Admin blocked at content proxy | VERIFIED — `get_regular_user` dep; `test_content_stream_admin_403` passes |
| T-04-05-02: No presigned URL in proxy | VERIFIED — `presigned_mock.assert_not_called()` in `test_content_stream_no_presigned_url` |
| T-04-05-03: Range validation bounds | VERIFIED — `test_parse_range_416` confirms 416 on out-of-bounds |
| T-04-05-04: Non-recipient 404 | VERIFIED — unshared doc returns 404; share recipient gets 200 |
| T-04-05-05: pdf_open_mode Literal | VERIFIED — `test_patch_preferences_invalid_value` confirms 422 on invalid value |
## Known Stubs
None — all behavior is fully implemented and wired.
## Threat Flags
None — no new network endpoints, auth paths, or file access patterns beyond what was planned in the threat model.
## Self-Check: PASSED
- [x] `backend/api/documents.py` — stream_document_content endpoint exists
- [x] `backend/api/auth.py` — /me/preferences routes registered (GET + PATCH)
- [x] `backend/db/models.py` — pdf_open_mode column on User model
- [x] `backend/tests/test_documents.py` — 8 streaming proxy tests pass
- [x] `backend/tests/test_auth_api.py` — 7 preferences tests pass
- [x] Commits: 8e6cb6e (RED), f868a4e (GREEN Task 1), 2a0df32 (Task 2)
- [x] Full suite: 137 passed, 1 pre-existing failure (test_extract_docx — missing docx module)
@@ -0,0 +1,109 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: "06"
subsystem: admin-audit
tags: [audit-log, admin-api, celery, csv-export, minio, security]
dependency_graph:
requires: ["04-03", "04-04"]
provides: ["ADMIN-06", "D-17"]
affects: ["backend/api/audit.py", "backend/tasks/audit_tasks.py", "backend/celery_app.py", "backend/main.py"]
tech_stack:
added: []
patterns:
- "Admin-only audit log viewer with paginated, filtered SQLAlchemy query"
- "Streaming CSV export via FastAPI StreamingResponse + csv.DictWriter"
- "Celery beat crontab schedule at midnight UTC for daily MinIO export"
- "Deferred imports inside async task body to prevent circular imports"
- "_audit_to_dict() safe whitelist serializer pattern (mirrors _user_to_dict)"
key_files:
created:
- backend/api/audit.py
- backend/tasks/audit_tasks.py
modified:
- backend/celery_app.py
- backend/main.py
decisions:
- "CSV export reuses _audit_to_dict() whitelist helper — single source of truth for safe field set"
- "audit_tasks.* routed to documents queue — reuses existing documents worker (no new queue needed)"
- "crontab alias uses _crontab (underscore prefix) consistent with existing _timedelta alias"
metrics:
duration_seconds: 262
completed_date: "2026-05-25"
tasks_completed: 2
files_created: 2
files_modified: 2
---
# Phase 4 Plan 06: Admin Audit Log API + Celery Daily Export Summary
**One-liner:** Admin-only paginated/filtered audit log viewer with CSV streaming export (ADMIN-06) and midnight-UTC Celery beat task uploading daily CSVs to MinIO audit-logs bucket (D-17).
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Admin audit log viewer + CSV export | 364447d | backend/api/audit.py, backend/main.py |
| 2 | Celery daily export task + beat schedule | f89f787 | backend/tasks/audit_tasks.py, backend/celery_app.py |
## What Was Built
### Task 1: backend/api/audit.py
Two admin-only endpoints protected by `Depends(get_current_admin)`:
- `GET /api/admin/audit-log` — paginated (page/per_page), filtered (start, end, user_id, event_type). Returns `{items, total, page, per_page}`. Runs a separate COUNT query for total using the same filters.
- `GET /api/admin/audit-log/export` — same filter params, no pagination; streams CSV with `Content-Disposition: attachment; filename=audit-export.csv`.
The `_audit_to_dict()` helper is the single source of truth for the safe field set: `id, event_type, user_id, actor_id, resource_id, ip_address, metadata_, created_at`. The dict literal contains no `filename`, `extracted_text`, `password_hash`, or `credentials_enc` keys. Both the JSON and CSV paths use this same helper.
### Task 2: backend/tasks/audit_tasks.py + celery_app.py
- `audit_log_daily_export` Celery task: sync entry point → `asyncio.run(_run_daily_export())`.
- `_run_daily_export()`: queries yesterday's `AuditLog` rows (UTC midnight to midnight), writes CSV via `csv.DictWriter`, uploads to MinIO via `put_object_raw(bucket="audit-logs", key="audit-logs/YYYY-MM-DD.csv", ...)`. Wraps everything in try/except — returns `{"exported": 0, "error": str(e)}` on failure.
- All imports deferred inside `_run_daily_export()` body (same circular-import-prevention pattern as `document_tasks._run`).
- `celery_app.py`: `_crontab` aliased import, beat entry `"audit-log-daily-export"` at `_crontab(hour=0, minute=0)`, task route `"tasks.audit_tasks.*": {"queue": "documents"}`.
## Deviations from Plan
None — plan executed exactly as written.
## Security Invariants Verified
| Threat ID | Check | Result |
|-----------|-------|--------|
| T-04-06-01 | `Depends(get_current_admin)` on both endpoints (grep: 2 occurrences at lines 94, 129) | PASS |
| T-04-06-02 | `_audit_to_dict()` dict literal contains no forbidden keys (grep: filename/extracted_text only in comments) | PASS |
| T-04-06-03 | CSV export uses same `_audit_to_dict()` helper as JSON viewer | PASS |
| T-04-06-04 | `put_object_raw` uses `bucket="audit-logs"` (not documents bucket) | PASS |
## Test Results
```
tests/test_audit.py: 4 xfailed (stub tests from Wave 0 — plan 04-06 implements the API,
detailed integration tests will be written in the full TDD pass)
Full suite: 1 failed (test_extractor.py::test_extract_docx — pre-existing missing module,
out of scope), 130 passed, 7 skipped, 35 xfailed
```
Pre-existing failures (not caused by this plan):
- `test_extractor.py::test_extract_docx` — missing python-docx module in local env
- `test_documents.py::test_content_stream_200` — intentional TDD RED from plan 04-05 (commit 8e6cb6e)
## Known Stubs
None — both endpoints are fully implemented and wired.
## Threat Flags
None — no new network endpoints or trust boundaries beyond those documented in the plan's threat model.
## Self-Check: PASSED
- [x] `backend/api/audit.py` exists: FOUND
- [x] `backend/tasks/audit_tasks.py` exists: FOUND
- [x] Task 1 commit 364447d: FOUND
- [x] Task 2 commit f89f787: FOUND
- [x] `python3 -c "from api.audit import router"` exits 0: PASS
- [x] `python3 -c "from tasks.audit_tasks import audit_log_daily_export"` exits 0: PASS
- [x] `beat_schedule` contains `audit-log-daily-export`: PASS
- [x] `task_routes` contains `tasks.audit_tasks.*`: PASS
+97
View File
@@ -67,6 +67,103 @@ cd frontend && npm run dev
cd backend && pytest -v cd backend && pytest -v
``` ```
## Testing Protocol (Non-Negotiable)
Every feature, function, and bug fix requires tests. No phase or plan may advance until all tests pass.
### Rules
- **Coverage**: Every new function, endpoint, and UI component must have at least one test — unit for isolated logic, integration for DB/service boundaries, E2E for critical user flows
- **Gate**: `pytest -v` (backend) and frontend test suite must pass with zero failures before marking a plan complete or advancing to the next phase
- **Bug fixes**: Must fix the root cause, not work around it. Maximum 50 lines of changed code per fix. If a fix requires more, it is scope-creep and must be broken into a separate plan
- **No workarounds**: `# type: ignore`, `noqa`, skipping a test, or adding a `try/except` that silently swallows an error are prohibited as bug fixes
- **Regression**: Any time a bug is fixed, a test must be added that would have caught it
### Test types per layer
| Layer | Required test type |
|---|---|
| Service / business logic | Unit tests with mocked dependencies |
| DB queries / ORM | Integration tests against real PostgreSQL (not SQLite for quota/UUID tests) |
| API endpoints | `httpx.AsyncClient` integration tests with real DB fixtures |
| Auth flows | Full round-trip tests (register → login → TOTP → refresh → revoke) |
| Security invariants | Dedicated negative tests (wrong owner → 403/404, admin → 403, replay → 401) |
| Frontend | Vitest unit tests for stores/composables; Playwright or Cypress for critical flows |
---
## Security Protocol (Non-Negotiable)
A dedicated **security agent** runs after every plan execution and before any phase is marked complete. This agent has full read/write/edit access to the entire codebase and is the final gate before advancement.
### Security agent mandate
The security agent must check — and fix — every class of vulnerability listed below. It may not flag and defer; it must resolve or escalate blocking issues.
#### OWASP Top 10 + auth-specific
| Threat | Required mitigation |
|---|---|
| SQL injection | All queries via ORM or parameterized statements — zero raw string interpolation |
| XSS | CSP headers, `httpOnly` cookies, no `innerHTML` with user data, Vue template auto-escaping never bypassed |
| CSRF | `SameSite=Strict` cookie + `Origin`/`Referer` header validation on all state-changing endpoints |
| Broken auth | Short-lived JWT (≤15 min), refresh rotation, family revocation on reuse, constant-time comparison |
| IDOR / broken access control | Every resource endpoint asserts `resource.user_id == current_user.id`; admin blocked from document content |
| Security misconfiguration | No debug mode in production, no stack traces in API responses, no default credentials |
| Sensitive data exposure | Passwords hashed Argon2id, PII fields encrypted at rest, `credentials_enc` never in API responses |
| Insecure deserialization | No `pickle`, no `eval`, no dynamic `__import__`; all user-supplied data validated via Pydantic |
| Vulnerable dependencies | `pip audit` / `npm audit` run; critical/high CVEs blocked |
| Insufficient logging | All auth events, quota violations, and admin actions written to audit log without document content |
#### Advanced threats
- **Path traversal**: All file path construction uses `os.path.basename` / `pathlib` — never joins user-supplied strings directly
- **SSRF**: All outbound HTTP (HIBP, cloud OAuth) via an allowlisted client; user-supplied URLs for WebDAV/Nextcloud must pass hostname allowlist
- **Timing attacks**: `hmac.compare_digest` / `secrets.compare_digest` for all token, TOTP, and backup-code comparison — no `==`
- **Race conditions / TOCTOU**: Quota enforcement via single atomic `UPDATE … RETURNING` — never read-then-write in Python
- **Mass assignment**: Pydantic models explicitly declare every accepted field; no `**kwargs` passthrough from request body to ORM
- **Privilege escalation**: `get_regular_user` and `get_current_admin` deps checked on every endpoint; no role elevation path exists
- **Token replay**: JTI stored in DB; used TOTP codes invalidated within the 90 s window; refresh token family revocation on reuse
#### Zero-day / defense-in-depth
- **Minimal attack surface**: Every endpoint that is not needed is absent — no commented-out code, no `TODO: remove` endpoints left alive
- **Principle of least privilege**: `docuvault_app` DB role has DML only; `docuvault_migrate` has DDL; MinIO bucket policy denies public access
- **Secrets in env only**: No credentials, API keys, or signing secrets in code, commits, or `.env` files checked in; `.gitignore` enforces this
- **Dependency pinning**: `requirements.txt` and `package-lock.json` pin exact versions; no floating `>=` for security-critical packages (PyJWT, pwdlib, cryptography)
- **Container hardening**: Non-root user in Dockerfile, read-only filesystem where possible, no `--privileged` containers
- **Header hardening**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` on every response
### Database user table encryption
Sensitive user PII (email, display name) must be encrypted at the application layer before storage:
- Encryption: AES-256-GCM via `cryptography` library, per-row nonce, master key from env var
- Key derivation: HKDF-SHA256 with `purpose=b"user-pii"` salt — same pattern as cloud credentials
- Admin queries: never return plaintext PII for users other than the requesting user
- Indexing: email lookup uses a deterministic HMAC-SHA256 index (`email_hmac` column) — the encrypted column is never used for WHERE clauses
### Login token hardening (state of the art)
- **Algorithm**: ES256 (ECDSA P-256) — asymmetric; the private key signs, the public key verifies; a leaked public key cannot forge tokens
- **Access token TTL**: 15 minutes maximum
- **Refresh token**: 30-day httpOnly Strict cookie; rotated on every use; reuse of a rotated token revokes entire family and fires a security alert email
- **JTI claim**: Every token has a unique `jti`; revoked JTIs stored in Redis with TTL matching the token lifetime
- **Token binding**: Access token embeds a `fgp` (fingerprint) claim = HMAC of `User-Agent + Accept-Language`; backend validates on every request
- **Rotation on privilege change**: Password change, TOTP enroll/revoke, and account deactivation immediately revoke all active sessions
### Security gate checklist (must all pass before phase advances)
- [ ] `bandit -r backend/` — zero HIGH severity findings
- [ ] `pip audit` — zero critical/high CVEs
- [ ] `npm audit --audit-level=high` — zero high/critical vulnerabilities
- [ ] All security-invariant tests pass (wrong owner, admin block, token replay, CSRF)
- [ ] No new `# noqa: S` suppressions without a documented justification comment
- [ ] Admin endpoints verified to never return `password_hash`, `credentials_enc`, or document content
- [ ] No hardcoded secrets detected by `git secrets` / `trufflehog`
---
## Security Requirements (Non-Negotiable) ## Security Requirements (Non-Negotiable)
- Rate limiting on all auth endpoints (login, register, password reset, TOTP) - Rate limiting on all auth endpoints (login, register, password reset, TOTP)
+75 -9
View File
@@ -112,6 +112,21 @@ async def create_folder(
if parent is None or parent.user_id != current_user.id: if parent is None or parent.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Parent folder not found") raise HTTPException(status_code=404, detail="Parent folder not found")
# Explicit duplicate check — UniqueConstraint won't fire when parent_id IS NULL
# because SQL treats NULL as distinct from NULL in unique indexes.
dup = await session.execute(
select(Folder).where(
Folder.user_id == current_user.id,
Folder.name == body.name,
Folder.parent_id == parent_uuid,
)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
folder = Folder( folder = Folder(
user_id=current_user.id, user_id=current_user.id,
name=body.name, name=body.name,
@@ -144,23 +159,57 @@ async def create_folder(
@router.get("") @router.get("")
async def list_folders( async def list_folders(
parent_id: Optional[str] = None,
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""List the current user's top-level folders (parent_id IS NULL). """List the current user's folders at a given level.
FOLD-02: returns only folders belonging to current_user with no parent. FOLD-02: when parent_id is omitted, returns root folders (parent_id IS NULL).
When parent_id is supplied, returns that folder's direct children (asserts ownership).
Each folder includes has_children so the frontend can hide expand arrows on leaf nodes.
""" """
parent_uuid: Optional[uuid.UUID] = None
if parent_id is not None:
try:
parent_uuid = uuid.UUID(parent_id)
except ValueError:
raise HTTPException(status_code=404, detail="Parent folder not found")
parent_folder = await session.get(Folder, parent_uuid)
if parent_folder is None or parent_folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Parent folder not found")
if parent_uuid is None:
where_clause = Folder.parent_id.is_(None)
else:
where_clause = Folder.parent_id == parent_uuid
result = await session.execute( result = await session.execute(
select(Folder) select(Folder)
.where( .where(Folder.user_id == current_user.id, where_clause)
Folder.user_id == current_user.id,
Folder.parent_id.is_(None),
)
.order_by(Folder.name) .order_by(Folder.name)
) )
folders = result.scalars().all() folders = result.scalars().all()
return {"items": [_folder_to_dict(f) for f in folders]}
# One extra query to know which of these folders have sub-folders.
# Allows the frontend to hide expand arrows on leaf nodes without extra round-trips.
folder_ids = [f.id for f in folders]
folders_with_children: set = set()
if folder_ids:
children_result = await session.execute(
select(Folder.parent_id.distinct()).where(
Folder.user_id == current_user.id,
Folder.parent_id.in_(folder_ids),
)
)
folders_with_children = {row[0] for row in children_result}
return {
"items": [
{**_folder_to_dict(f), "has_children": f.id in folders_with_children}
for f in folders
]
}
# ── GET /api/folders/{folder_id} ────────────────────────────────────────────── # ── GET /api/folders/{folder_id} ──────────────────────────────────────────────
@@ -235,6 +284,22 @@ async def rename_folder(
raise HTTPException(status_code=404, detail="Folder not found") raise HTTPException(status_code=404, detail="Folder not found")
old_name = folder.name old_name = folder.name
# Explicit duplicate check — same NULL parent_id issue as create_folder.
if body.name != folder.name:
dup = await session.execute(
select(Folder).where(
Folder.user_id == current_user.id,
Folder.name == body.name,
Folder.parent_id == folder.parent_id,
Folder.id != folder.id,
)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
folder.name = body.name folder.name = body.name
try: try:
await session.commit() await session.commit()
@@ -303,7 +368,8 @@ async def delete_folder(
" WHERE f.user_id = :uid" " WHERE f.user_id = :uid"
") SELECT id FROM subtree" ") SELECT id FROM subtree"
), ),
{"root_id": str(folder.id), "uid": str(current_user.id)}, # Use .hex (no dashes) — SQLite stores UUID as 32-char hex; PostgreSQL accepts both.
{"root_id": folder.id.hex, "uid": current_user.id.hex},
) )
subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()] subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()]
except OperationalError: except OperationalError:
@@ -344,7 +410,7 @@ async def delete_folder(
"CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END " "CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END "
"WHERE user_id = :uid" "WHERE user_id = :uid"
), ),
{"delta": total_bytes, "uid": str(current_user.id)}, {"delta": total_bytes, "uid": current_user.id.hex},
) )
# Delete MinIO objects best-effort (per-object, never abort on failure) # Delete MinIO objects best-effort (per-object, never abort on failure)
+11 -3
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Topic, User from db.models import Document, Topic, User
from deps.auth import get_current_user from deps.auth import get_current_user
from deps.db import get_db from deps.db import get_db
from services import classifier, storage from services import classifier, storage
@@ -137,10 +137,18 @@ async def suggest_topics(
"""Suggest topics for a document using AI. """Suggest topics for a document using AI.
D-11: classifier uses the user's namespace (system + user topics) for suggestions. D-11: classifier uses the user's namespace (system + user topics) for suggestions.
D-16 / SEC-IDOR: asserts document ownership — cross-user access returns 404
to prevent document ID enumeration (same pattern as documents router).
""" """
meta = await storage.get_metadata(session, body.document_id) try:
if meta is None: uid = uuid.UUID(body.document_id)
except ValueError:
raise HTTPException(404, "Document not found") raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
try: try:
suggestions = await classifier.suggest_topics_for_document(session, body.document_id) suggestions = await classifier.suggest_topics_for_document(session, body.document_id)
except Exception as e: except Exception as e:
+1 -1
View File
@@ -356,7 +356,7 @@ async def check_hibp(password: str) -> bool:
Returns True if the password has been breached, False otherwise. Returns True if the password has been breached, False otherwise.
On network error: logs a warning and returns False (fail-open, T-02-06). On network error: logs a warning and returns False (fail-open, T-02-06).
""" """
sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() sha1 = hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper() # noqa: S324 — HIBP k-anonymity protocol mandates SHA-1; not used as a security primitive
prefix, suffix = sha1[:5], sha1[5:] prefix, suffix = sha1[:5], sha1[5:]
try: try:
+455 -84
View File
@@ -1,123 +1,494 @@
""" """
Folder API tests — Wave 0 xfail stubs for Phase 4. Folder API tests — FOLD-01 through FOLD-05.
All tests in this file are xfail stubs. They will be implemented in Plans 04-02 Covers:
through 04-04. The stubs ensure pytest collects them and keeps CI green before POST /api/folders — create (FOLD-01)
implementation code exists. GET /api/folders — list root / children (FOLD-02)
GET /api/folders/{id} — get + breadcrumb (FOLD-02, FOLD-05)
PATCH /api/folders/{id} — rename (FOLD-03)
DELETE /api/folders/{id} — delete cascade (FOLD-03)
PATCH /api/documents/{id}/folder — move document (FOLD-04)
Security invariants (T-04-03-xx) tested throughout.
""" """
from __future__ import annotations from __future__ import annotations
import os import uuid as _uuid
import pytest import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Folder, Quota, User
# --------------------------------------------------------------------------- # ── Helpers ───────────────────────────────────────────────────────────────────
# FOLD-01: Create folder
# ---------------------------------------------------------------------------
@pytest.mark.xfail(strict=False) async def _create_folder(db: AsyncSession, user: User, name: str, parent=None) -> Folder:
async def test_create_folder(async_client, auth_user): """Create a Folder row directly via ORM."""
"""POST /api/folders creates a folder, returns 201.""" f = Folder(
pytest.xfail("not implemented yet") user_id=user.id,
name=name,
parent_id=parent.id if parent else None,
)
db.add(f)
await db.commit()
await db.refresh(f)
return f
@pytest.mark.xfail(strict=False) async def _create_document(db: AsyncSession, user: User, *, folder=None) -> Document:
async def test_create_folder_duplicate_name(async_client, auth_user): """Create a minimal Document row via ORM (no MinIO object needed)."""
doc_id = _uuid.uuid4()
doc = Document(
id=doc_id,
user_id=user.id,
filename="test.pdf",
content_type="application/pdf",
size_bytes=1024,
storage_backend="minio",
status="uploaded",
object_key=f"{user.id}/{doc_id}/{_uuid.uuid4()}.pdf",
folder_id=folder.id if folder else None,
)
db.add(doc)
await db.commit()
await db.refresh(doc)
return doc
# ── FOLD-01: Create folder ────────────────────────────────────────────────────
async def test_create_root_folder(async_client, auth_user):
"""POST /api/folders creates a root folder, returns 201 with id/name."""
resp = await async_client.post(
"/api/folders",
json={"name": "MyFolder"},
headers=auth_user["headers"],
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "MyFolder"
assert data["parent_id"] is None
assert "id" in data
async def test_create_subfolder(async_client, auth_user, db_session):
"""POST /api/folders with parent_id creates a child folder."""
parent = await _create_folder(db_session, auth_user["user"], "Parent")
resp = await async_client.post(
"/api/folders",
json={"name": "Child", "parent_id": str(parent.id)},
headers=auth_user["headers"],
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Child"
assert data["parent_id"] == str(parent.id)
async def test_create_folder_duplicate_name_409(async_client, auth_user, db_session):
"""POST /api/folders with same name under same parent returns 409.""" """POST /api/folders with same name under same parent returns 409."""
pytest.xfail("not implemented yet") await _create_folder(db_session, auth_user["user"], "DupFolder")
resp = await async_client.post(
"/api/folders",
json={"name": "DupFolder"},
headers=auth_user["headers"],
)
assert resp.status_code == 409
# --------------------------------------------------------------------------- async def test_create_folder_invalid_parent_404(async_client, auth_user):
# FOLD-02: Rename folder """POST /api/folders with non-existent parent_id returns 404."""
# --------------------------------------------------------------------------- resp = await async_client.post(
"/api/folders",
json={"name": "Orphan", "parent_id": str(_uuid.uuid4())},
headers=auth_user["headers"],
)
assert resp.status_code == 404
@pytest.mark.xfail(strict=False) async def test_create_folder_other_users_parent_404(async_client, auth_user, admin_user, db_session):
async def test_rename_folder(async_client, auth_user): """POST /api/folders with parent owned by another user returns 404 (IDOR)."""
"""PATCH /api/folders/{id} changes name, returns 200.""" other_parent = await _create_folder(db_session, admin_user["user"], "AdminParent")
pytest.xfail("not implemented yet")
resp = await async_client.post(
"/api/folders",
json={"name": "Steal", "parent_id": str(other_parent.id)},
headers=auth_user["headers"],
)
assert resp.status_code == 404
@pytest.mark.xfail(strict=False) async def test_create_folder_requires_auth(async_client):
async def test_rename_folder_wrong_owner(async_client, auth_user): """POST /api/folders without auth returns 401 or 403."""
"""PATCH /api/folders/{id} by non-owner returns 404.""" resp = await async_client.post("/api/folders", json={"name": "Unauth"})
pytest.xfail("not implemented yet") assert resp.status_code in (401, 403)
# --------------------------------------------------------------------------- # ── FOLD-02: List folders ─────────────────────────────────────────────────────
# FOLD-03: Delete folder
# ---------------------------------------------------------------------------
@pytest.mark.xfail(strict=False) async def test_list_root_folders(async_client, auth_user, db_session):
async def test_delete_empty_folder(async_client, auth_user): """GET /api/folders returns only root folders for current user."""
await _create_folder(db_session, auth_user["user"], "RootA")
await _create_folder(db_session, auth_user["user"], "RootB")
resp = await async_client.get("/api/folders", headers=auth_user["headers"])
assert resp.status_code == 200
items = resp.json()["items"]
names = {f["name"] for f in items}
assert "RootA" in names
assert "RootB" in names
async def test_list_root_folders_excludes_other_users(async_client, auth_user, admin_user, db_session):
"""GET /api/folders does not return other users' folders."""
await _create_folder(db_session, admin_user["user"], "AdminRoot")
resp = await async_client.get("/api/folders", headers=auth_user["headers"])
names = {f["name"] for f in resp.json()["items"]}
assert "AdminRoot" not in names
async def test_list_root_folders_excludes_subfolders(async_client, auth_user, db_session):
"""GET /api/folders (no parent_id) does not return nested folders."""
root = await _create_folder(db_session, auth_user["user"], "Root")
await _create_folder(db_session, auth_user["user"], "Child", parent=root)
resp = await async_client.get("/api/folders", headers=auth_user["headers"])
items = resp.json()["items"]
names = [f["name"] for f in items]
assert "Root" in names
assert "Child" not in names
async def test_list_children(async_client, auth_user, db_session):
"""GET /api/folders?parent_id=X returns direct children of X."""
root = await _create_folder(db_session, auth_user["user"], "Root")
child = await _create_folder(db_session, auth_user["user"], "Child", parent=root)
resp = await async_client.get(
f"/api/folders?parent_id={root.id}", headers=auth_user["headers"]
)
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
assert items[0]["id"] == str(child.id)
async def test_list_children_other_user_parent_404(async_client, auth_user, admin_user, db_session):
"""GET /api/folders?parent_id= with other user's folder returns 404 (T-04-03-04)."""
other_folder = await _create_folder(db_session, admin_user["user"], "OtherFolder")
resp = await async_client.get(
f"/api/folders?parent_id={other_folder.id}", headers=auth_user["headers"]
)
assert resp.status_code == 404
async def test_list_folders_has_children_field(async_client, auth_user, db_session):
"""GET /api/folders includes has_children: true for folders with sub-folders."""
root = await _create_folder(db_session, auth_user["user"], "WithChild")
await _create_folder(db_session, auth_user["user"], "Leaf", parent=root)
await _create_folder(db_session, auth_user["user"], "NoChild")
resp = await async_client.get("/api/folders", headers=auth_user["headers"])
items = {f["name"]: f for f in resp.json()["items"]}
assert items["WithChild"]["has_children"] is True
assert items["NoChild"]["has_children"] is False
# ── FOLD-02 / FOLD-05: Get folder + breadcrumb ────────────────────────────────
async def test_get_folder(async_client, auth_user, db_session):
"""GET /api/folders/{id} returns folder metadata."""
folder = await _create_folder(db_session, auth_user["user"], "GetMe")
resp = await async_client.get(f"/api/folders/{folder.id}", headers=auth_user["headers"])
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "GetMe"
assert data["id"] == str(folder.id)
async def test_get_folder_breadcrumb_single(async_client, auth_user, db_session):
"""GET /api/folders/{id} breadcrumb for root folder = [{id, name}]."""
folder = await _create_folder(db_session, auth_user["user"], "RootFolder")
resp = await async_client.get(f"/api/folders/{folder.id}", headers=auth_user["headers"])
crumbs = resp.json()["breadcrumb"]
assert len(crumbs) == 1
assert crumbs[0]["name"] == "RootFolder"
async def test_get_folder_breadcrumb_deep(async_client, auth_user, db_session):
"""GET /api/folders/{id} breadcrumb is root-first for 3-level hierarchy."""
root = await _create_folder(db_session, auth_user["user"], "Root")
mid = await _create_folder(db_session, auth_user["user"], "Mid", parent=root)
leaf = await _create_folder(db_session, auth_user["user"], "Leaf", parent=mid)
resp = await async_client.get(f"/api/folders/{leaf.id}", headers=auth_user["headers"])
assert resp.status_code == 200
crumbs = resp.json()["breadcrumb"]
assert len(crumbs) == 3
assert crumbs[0]["name"] == "Root"
assert crumbs[1]["name"] == "Mid"
assert crumbs[2]["name"] == "Leaf"
async def test_get_folder_wrong_owner_404(async_client, auth_user, admin_user, db_session):
"""GET /api/folders/{id} for another user's folder returns 404 (T-04-03-04)."""
other = await _create_folder(db_session, admin_user["user"], "OtherFolder")
resp = await async_client.get(f"/api/folders/{other.id}", headers=auth_user["headers"])
assert resp.status_code == 404
async def test_get_folder_not_found_404(async_client, auth_user):
"""GET /api/folders/{id} with non-existent id returns 404."""
resp = await async_client.get(f"/api/folders/{_uuid.uuid4()}", headers=auth_user["headers"])
assert resp.status_code == 404
# ── FOLD-03: Rename folder ────────────────────────────────────────────────────
async def test_rename_folder(async_client, auth_user, db_session):
"""PATCH /api/folders/{id} changes folder name, returns 200."""
folder = await _create_folder(db_session, auth_user["user"], "OldName")
resp = await async_client.patch(
f"/api/folders/{folder.id}",
json={"name": "NewName"},
headers=auth_user["headers"],
)
assert resp.status_code == 200
assert resp.json()["name"] == "NewName"
async def test_rename_folder_duplicate_409(async_client, auth_user, db_session):
"""PATCH /api/folders/{id} to a name that already exists at same level → 409."""
await _create_folder(db_session, auth_user["user"], "Existing")
to_rename = await _create_folder(db_session, auth_user["user"], "ToRename")
resp = await async_client.patch(
f"/api/folders/{to_rename.id}",
json={"name": "Existing"},
headers=auth_user["headers"],
)
assert resp.status_code == 409
async def test_rename_folder_wrong_owner_404(async_client, auth_user, admin_user, db_session):
"""PATCH /api/folders/{id} for another user's folder returns 404 (T-04-03-04)."""
other = await _create_folder(db_session, admin_user["user"], "AdminFolder")
resp = await async_client.patch(
f"/api/folders/{other.id}",
json={"name": "Hijacked"},
headers=auth_user["headers"],
)
assert resp.status_code == 404
# ── FOLD-03: Delete folder ────────────────────────────────────────────────────
async def test_delete_empty_folder(async_client, auth_user, db_session):
"""DELETE /api/folders/{id} on empty folder returns 204.""" """DELETE /api/folders/{id} on empty folder returns 204."""
pytest.xfail("not implemented yet") folder = await _create_folder(db_session, auth_user["user"], "Empty")
resp = await async_client.delete(f"/api/folders/{folder.id}", headers=auth_user["headers"])
assert resp.status_code == 204
# Verify it's gone
resp2 = await async_client.get(f"/api/folders/{folder.id}", headers=auth_user["headers"])
assert resp2.status_code == 404
@pytest.mark.xfail(strict=False) async def test_delete_folder_cascade_documents(async_client, auth_user, db_session):
async def test_delete_folder_cascade(async_client, auth_user): """DELETE /api/folders/{id} cascades to documents inside it."""
"""DELETE /api/folders/{id} on non-empty folder deletes all docs + decrements quota.""" folder = await _create_folder(db_session, auth_user["user"], "WithDocs")
pytest.xfail("not implemented yet") doc = await _create_document(db_session, auth_user["user"], folder=folder)
resp = await async_client.delete(f"/api/folders/{folder.id}", headers=auth_user["headers"])
assert resp.status_code == 204
# Document should be deleted
doc_resp = await async_client.get(f"/api/documents/{doc.id}", headers=auth_user["headers"])
assert doc_resp.status_code == 404
@pytest.mark.xfail(strict=False) async def test_delete_folder_cascade_quota(async_client, auth_user, db_session):
async def test_delete_folder_wrong_owner(async_client, auth_user): """DELETE /api/folders/{id} decrements quota by sum of deleted doc sizes."""
"""DELETE /api/folders/{id} by non-owner returns 404.""" folder = await _create_folder(db_session, auth_user["user"], "ForQuota")
pytest.xfail("not implemented yet")
# Manually set quota used_bytes to 2048 (2 x 1024)
quota = await db_session.get(Quota, auth_user["user"].id)
quota.used_bytes = 2048
await db_session.commit()
await _create_document(db_session, auth_user["user"], folder=folder) # 1024 bytes
resp = await async_client.delete(f"/api/folders/{folder.id}", headers=auth_user["headers"])
assert resp.status_code == 204
await db_session.refresh(quota)
assert quota.used_bytes == 1024 # decremented by 1024
# --------------------------------------------------------------------------- async def test_delete_subfolder_not_in_parent_list(async_client, auth_user, db_session):
# FOLD-04: Move document """DELETE /api/folders/{id} on subfolder — parent folder still exists after."""
# --------------------------------------------------------------------------- root = await _create_folder(db_session, auth_user["user"], "Root")
sub = await _create_folder(db_session, auth_user["user"], "Sub", parent=root)
resp = await async_client.delete(f"/api/folders/{sub.id}", headers=auth_user["headers"])
assert resp.status_code == 204
# Root still exists
root_resp = await async_client.get(f"/api/folders/{root.id}", headers=auth_user["headers"])
assert root_resp.status_code == 200
@pytest.mark.xfail(strict=False) async def test_delete_folder_wrong_owner_404(async_client, auth_user, admin_user, db_session):
async def test_move_document(async_client, auth_user): """DELETE /api/folders/{id} for another user's folder returns 404 (T-04-03-04)."""
other = await _create_folder(db_session, admin_user["user"], "AdminFolder")
resp = await async_client.delete(f"/api/folders/{other.id}", headers=auth_user["headers"])
assert resp.status_code == 404
async def test_delete_folder_not_found_404(async_client, auth_user):
"""DELETE /api/folders/{id} with non-existent id returns 404."""
resp = await async_client.delete(
f"/api/folders/{_uuid.uuid4()}", headers=auth_user["headers"]
)
assert resp.status_code == 404
# ── FOLD-04: Move document ────────────────────────────────────────────────────
async def test_move_document_to_folder(async_client, auth_user, db_session):
"""PATCH /api/documents/{id}/folder moves doc to target folder, returns 200.""" """PATCH /api/documents/{id}/folder moves doc to target folder, returns 200."""
pytest.xfail("not implemented yet") folder = await _create_folder(db_session, auth_user["user"], "Destination")
doc = await _create_document(db_session, auth_user["user"])
resp = await async_client.patch(
@pytest.mark.xfail(strict=False) f"/api/documents/{doc.id}/folder",
async def test_move_wrong_owner_404(async_client, auth_user): json={"folder_id": str(folder.id)},
"""PATCH /api/documents/{id}/folder where doc or target folder belongs to other user returns 404.""" headers=auth_user["headers"],
pytest.xfail("not implemented yet")
# ---------------------------------------------------------------------------
# FOLD-05: Breadcrumb, sort, FTS
# ---------------------------------------------------------------------------
@pytest.mark.xfail(strict=False)
async def test_breadcrumb_path(async_client, auth_user):
"""GET /api/folders/{id} returns breadcrumb array of {id, name} from root to current."""
pytest.xfail("not implemented yet")
@pytest.mark.xfail(strict=False)
async def test_document_sort(async_client, auth_user):
"""GET /api/documents?sort=name|date|size returns correctly ordered results."""
pytest.xfail("not implemented yet")
@pytest.mark.xfail(strict=False)
@pytest.mark.skipif(
not os.environ.get("INTEGRATION"),
reason="requires PostgreSQL",
) )
async def test_fts_search(async_client, auth_user): assert resp.status_code == 200
"""GET /api/documents?q=term returns matching docs only; requires PostgreSQL FTS.""" assert resp.json()["folder_id"] == str(folder.id)
pytest.xfail("not implemented yet")
@pytest.mark.xfail(strict=False) async def test_move_document_to_root(async_client, auth_user, db_session):
@pytest.mark.skipif( """PATCH /api/documents/{id}/folder with folder_id: null moves doc to root."""
not os.environ.get("INTEGRATION"), folder = await _create_folder(db_session, auth_user["user"], "Source")
reason="requires PostgreSQL", doc = await _create_document(db_session, auth_user["user"], folder=folder)
resp = await async_client.patch(
f"/api/documents/{doc.id}/folder",
json={"folder_id": None},
headers=auth_user["headers"],
) )
async def test_fts_search_scoped_to_owner(async_client, auth_user): assert resp.status_code == 200
"""GET /api/documents?q=term does not return other user's matching docs.""" assert resp.json()["folder_id"] is None
pytest.xfail("not implemented yet")
async def test_move_document_wrong_owner_404(async_client, auth_user, admin_user, db_session):
"""PATCH /api/documents/{id}/folder for another user's doc returns 404 (IDOR)."""
other_doc = await _create_document(db_session, admin_user["user"])
folder = await _create_folder(db_session, auth_user["user"], "MyFolder")
resp = await async_client.patch(
f"/api/documents/{other_doc.id}/folder",
json={"folder_id": str(folder.id)},
headers=auth_user["headers"],
)
assert resp.status_code == 404
async def test_move_document_to_other_users_folder_404(async_client, auth_user, admin_user, db_session):
"""PATCH /api/documents/{id}/folder with target folder owned by another user → 404 (T-04-03-05)."""
doc = await _create_document(db_session, auth_user["user"])
other_folder = await _create_folder(db_session, admin_user["user"], "AdminFolder")
resp = await async_client.patch(
f"/api/documents/{doc.id}/folder",
json={"folder_id": str(other_folder.id)},
headers=auth_user["headers"],
)
assert resp.status_code == 404
async def test_move_document_invalid_folder_404(async_client, auth_user, db_session):
"""PATCH /api/documents/{id}/folder with non-existent folder_id returns 404."""
doc = await _create_document(db_session, auth_user["user"])
resp = await async_client.patch(
f"/api/documents/{doc.id}/folder",
json={"folder_id": str(_uuid.uuid4())},
headers=auth_user["headers"],
)
assert resp.status_code == 404
# ── FOLD-05: Sort and breadcrumb edge cases ───────────────────────────────────
async def test_list_folders_sorted_by_name(async_client, auth_user, db_session):
"""GET /api/folders returns folders ordered alphabetically by name."""
for name in ("Zebra", "Apple", "Mango"):
await _create_folder(db_session, auth_user["user"], name)
resp = await async_client.get("/api/folders", headers=auth_user["headers"])
names = [f["name"] for f in resp.json()["items"]]
assert names == sorted(names)
async def test_breadcrumb_two_levels(async_client, auth_user, db_session):
"""GET /api/folders/{id} breadcrumb for two-level tree is [root, child]."""
root = await _create_folder(db_session, auth_user["user"], "Root")
child = await _create_folder(db_session, auth_user["user"], "Child", parent=root)
resp = await async_client.get(f"/api/folders/{child.id}", headers=auth_user["headers"])
crumbs = resp.json()["breadcrumb"]
assert len(crumbs) == 2
assert crumbs[0]["id"] == str(root.id)
assert crumbs[1]["id"] == str(child.id)
# ── Security: admin blocked from folder endpoints ────────────────────────────
async def test_admin_cannot_access_folder_endpoints(async_client, admin_user, db_session):
"""Admin role is blocked from folder endpoints (T-04-03-01: get_regular_user only)."""
resp = await async_client.post(
"/api/folders", json={"name": "AdminFolder"}, headers=admin_user["headers"]
)
assert resp.status_code == 403
async def test_list_documents_scoped_to_folder(async_client, auth_user, db_session):
"""GET /api/documents?folder_id=X returns only docs in that folder."""
folder_a = await _create_folder(db_session, auth_user["user"], "FolderA")
folder_b = await _create_folder(db_session, auth_user["user"], "FolderB")
doc_a = await _create_document(db_session, auth_user["user"], folder=folder_a)
await _create_document(db_session, auth_user["user"], folder=folder_b)
resp = await async_client.get(
f"/api/documents?folder_id={folder_a.id}", headers=auth_user["headers"]
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["id"] == str(doc_a.id)
+2 -2
View File
@@ -30,7 +30,7 @@ async def test_lmstudio_health_check():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lmstudio_classify(): async def test_lmstudio_classify():
from ai.lmstudio_provider import LMStudioProvider from ai.lmstudio_provider import LMStudioProvider
from config import DEFAULT_SYSTEM_PROMPT from services.classifier import _DEFAULT_SYSTEM_PROMPT
provider = LMStudioProvider( provider = LMStudioProvider(
base_url="http://host.docker.internal:1234", base_url="http://host.docker.internal:1234",
@@ -39,7 +39,7 @@ async def test_lmstudio_classify():
result = await provider.classify( result = await provider.classify(
document_text="This document is an invoice for software development services.", document_text="This document is an invoice for software development services.",
existing_topics=["Finance", "Legal", "HR"], existing_topics=["Finance", "Legal", "HR"],
system_prompt=DEFAULT_SYSTEM_PROMPT, system_prompt=_DEFAULT_SYSTEM_PROMPT,
) )
# Result should have some topics assigned or suggested # Result should have some topics assigned or suggested
assert isinstance(result.topics, list) assert isinstance(result.topics, list)
+8 -5
View File
@@ -8,15 +8,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"pinia": "^2.1.0",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.3.0", "vue-router": "^4.3.0"
"pinia": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.2.0", "@vue/test-utils": "^2.4.10",
"tailwindcss": "^3.4.0", "autoprefixer": "^10.4.0",
"happy-dom": "^20.9.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"autoprefixer": "^10.4.0" "tailwindcss": "^3.4.0",
"vite": "^5.2.0",
"vitest": "^4.1.7"
} }
} }
@@ -106,7 +106,7 @@ const foldersStore = useFoldersStore()
const showShareModal = ref(false) const showShareModal = ref(false)
const showFolderPicker = ref(false) const showFolderPicker = ref(false)
const allFolders = computed(() => foldersStore.folders) const allFolders = computed(() => foldersStore.rootFolders)
function openShareModal() { function openShareModal() {
showShareModal.value = true showShareModal.value = true
@@ -0,0 +1,108 @@
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow hidden when folder is a known leaf (has_children === false) -->
<button
v-if="folder.has_children !== false && (!childrenLoaded || (children && children.length > 0))"
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse' : 'Expand'"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Spacer for leaf nodes -->
<span v-else class="w-5 h-5 shrink-0"></span>
<!-- Folder name (router-link) -->
<router-link
:to="`/folders/${folder.id}`"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
>
<svg
class="w-4 h-4 shrink-0"
:class="isActive ? 'text-indigo-500' : 'text-gray-400'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<span class="truncate">{{ folder.name }}</span>
</router-link>
</div>
<!-- Children (recursively rendered) -->
<div v-if="expanded && children && children.length > 0">
<FolderTreeItem
v-for="child in children"
:key="child.id"
:folder="child"
:depth="depth + 1"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useFoldersStore } from '../../stores/folders.js'
import * as api from '../../api/client.js'
const props = defineProps({
folder: { type: Object, required: true },
depth: { type: Number, default: 0 },
})
const route = useRoute()
const foldersStore = useFoldersStore()
const expanded = ref(false)
const children = ref(null) // null = not yet loaded
const childrenLoaded = ref(false)
const isActive = computed(() =>
route.params.folderId != null &&
(route.params.folderId === String(props.folder.id) || route.params.folderId === props.folder.id)
)
async function loadChildren() {
try {
const data = await api.listFolders(props.folder.id)
children.value = data.items ?? data
} catch {
children.value = []
}
childrenLoaded.value = true
}
async function toggleExpand() {
if (!childrenLoaded.value) {
await loadChildren()
}
expanded.value = !expanded.value
}
// Re-fetch children when any folder mutation occurs (create/rename/delete)
watch(() => foldersStore.treeVersion, () => {
if (expanded.value && childrenLoaded.value) {
loadChildren()
}
})
</script>
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FolderBreadcrumb from '../FolderBreadcrumb.vue'
function seg(id, name) { return { id, name } }
describe('FolderBreadcrumb', () => {
it('always renders a "Home" / "Folders" root button', () => {
const w = mount(FolderBreadcrumb, { props: { segments: [] } })
expect(w.find('button').exists()).toBe(true)
})
it('clicking root button emits navigate(null)', async () => {
const w = mount(FolderBreadcrumb, { props: { segments: [] } })
await w.find('button').trigger('click')
expect(w.emitted('navigate')).toBeTruthy()
expect(w.emitted('navigate')[0]).toEqual([null])
})
it('renders intermediate segments as clickable buttons', () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
})
// "Root" is intermediate (not last), "Test" is last (plain text)
const buttons = w.findAll('button')
// first button is "Home/Folders", second is "Root"
expect(buttons.length).toBe(2)
expect(buttons[1].text()).toBe('Root')
})
it('clicking intermediate segment emits navigate(id)', async () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
})
const buttons = w.findAll('button')
await buttons[1].trigger('click') // "Root" button
expect(w.emitted('navigate')).toBeTruthy()
expect(w.emitted('navigate')[0]).toEqual(['r1'])
})
it('renders last segment as plain non-interactive text', () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
})
// Last segment "Test" should be a <span>, not a button
const spans = w.findAll('span')
const lastSpan = spans.find(s => s.text() === 'Test')
expect(lastSpan).toBeTruthy()
})
it('last segment is NOT clickable (no navigate event)', async () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
})
const spans = w.findAll('span')
const lastSpan = spans.find(s => s.text() === 'Test')
if (lastSpan) await lastSpan.trigger('click')
// navigate should NOT have been emitted by clicking the last segment
const navigateEvents = (w.emitted('navigate') || []).filter(e => e[0] === 'f1')
expect(navigateEvents.length).toBe(0)
})
it('single segment: just root button + last segment as text', () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('f1', 'OnlyFolder')] },
})
// Only the "Home" button and "OnlyFolder" as plain text
const buttons = w.findAll('button')
expect(buttons.length).toBe(1) // just "Home"
expect(w.text()).toContain('OnlyFolder')
})
it('collapses >4 segments with ellipsis, preserving first and last two', () => {
const segments = [
seg('a', 'A'), seg('b', 'B'), seg('c', 'C'),
seg('d', 'D'), seg('e', 'E'),
]
const w = mount(FolderBreadcrumb, { props: { segments } })
const text = w.text()
expect(text).toContain('A') // first preserved
expect(text).toContain('…') // ellipsis present
expect(text).toContain('D') // second-to-last preserved
expect(text).toContain('E') // last preserved
expect(text).not.toContain('B') // middle segments collapsed
expect(text).not.toContain('C')
})
it('3 segments: all rendered without ellipsis', () => {
const segments = [seg('a', 'A'), seg('b', 'B'), seg('c', 'C')]
const w = mount(FolderBreadcrumb, { props: { segments } })
const text = w.text()
expect(text).toContain('A')
expect(text).toContain('B')
expect(text).toContain('C')
expect(text).not.toContain('…')
})
it('deep 3-level path: clicking middle segment navigates correctly', async () => {
const segments = [seg('root', 'Root'), seg('mid', 'Mid'), seg('cur', 'Current')]
const w = mount(FolderBreadcrumb, { props: { segments } })
const buttons = w.findAll('button')
// buttons[0] = Home, buttons[1] = Root, buttons[2] = Mid
await buttons[2].trigger('click')
const events = w.emitted('navigate') || []
const midClicks = events.filter(e => e[0] === 'mid')
expect(midClicks.length).toBe(1)
})
})
@@ -0,0 +1,175 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import FolderTreeItem from '../FolderTreeItem.vue'
const mockListFolders = vi.fn()
vi.mock('../../../api/client.js', () => ({
listFolders: (...a) => mockListFolders(...a),
}))
// Minimal router so router-link renders correctly
function makeRouter(currentPath = '/') {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div/>' } },
{ path: '/folders/:folderId', component: { template: '<div/>' } },
],
})
}
function makeFolder(overrides = {}) {
return {
id: overrides.id ?? 'f1',
name: overrides.name ?? 'Test',
parent_id: overrides.parent_id ?? null,
has_children: overrides.has_children ?? false,
created_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
async function mountItem(folderOverrides = {}, routerPath = '/') {
setActivePinia(createPinia())
const router = makeRouter(routerPath)
await router.push(routerPath)
await router.isReady()
return mount(FolderTreeItem, {
props: { folder: makeFolder(folderOverrides), depth: 1 },
global: { plugins: [router] },
})
}
describe('FolderTreeItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
})
// ── Expand arrow visibility ───────────────────────────────────────────────
it('hides expand arrow when has_children is false', async () => {
const w = await mountItem({ has_children: false })
// The expand button should not be rendered
const expandBtn = w.find('button')
expect(expandBtn.exists()).toBe(false)
})
it('shows expand arrow when has_children is true', async () => {
const w = await mountItem({ has_children: true })
const expandBtn = w.find('button')
expect(expandBtn.exists()).toBe(true)
})
it('shows expand arrow when has_children is undefined (unknown)', async () => {
// Newly created folders from optimistic push lack has_children
const folder = { id: 'f1', name: 'New', parent_id: null, created_at: '2026-01-01T00:00:00Z' }
setActivePinia(createPinia())
const router = makeRouter('/')
await router.push('/')
await router.isReady()
const w = mount(FolderTreeItem, {
props: { folder, depth: 1 },
global: { plugins: [router] },
})
// has_children undefined → arrow shown (conservative: assume could have children)
const expandBtn = w.find('button')
expect(expandBtn.exists()).toBe(true)
})
// ── Router-link href ──────────────────────────────────────────────────────
it('router-link points to /folders/<id>', async () => {
const w = await mountItem({ id: 'abc-123' })
const link = w.find('a')
expect(link.attributes('href')).toBe('/folders/abc-123')
})
// ── Active state ─────────────────────────────────────────────────────────
it('router-link has active class when route matches folder id', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/folders/f1')
await router.isReady()
const w = mount(FolderTreeItem, {
props: { folder: makeFolder({ id: 'f1' }), depth: 1 },
global: { plugins: [router] },
})
const link = w.find('a')
expect(link.classes()).toContain('bg-indigo-50')
})
it('router-link does NOT have active class when route does not match', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/folders/other-folder')
await router.isReady()
const w = mount(FolderTreeItem, {
props: { folder: makeFolder({ id: 'f1' }), depth: 1 },
global: { plugins: [router] },
})
const link = w.find('a')
expect(link.classes()).not.toContain('bg-indigo-50')
})
// ── Expand / collapse children ────────────────────────────────────────────
it('clicking expand arrow loads children from API', async () => {
const children = [makeFolder({ id: 'child-1', name: 'Child', parent_id: 'f1' })]
mockListFolders.mockResolvedValue({ items: children })
const w = await mountItem({ id: 'f1', has_children: true })
const btn = w.find('button')
await btn.trigger('click')
await w.vm.$nextTick()
expect(mockListFolders).toHaveBeenCalledWith('f1')
})
it('children are rendered after expand', async () => {
const children = [makeFolder({ id: 'child-1', name: 'ChildFolder', parent_id: 'f1' })]
mockListFolders.mockResolvedValue({ items: children })
const w = await mountItem({ id: 'f1', has_children: true })
await w.find('button').trigger('click')
await w.vm.$nextTick()
expect(w.text()).toContain('ChildFolder')
})
it('expand arrow disappears after loading empty children', async () => {
mockListFolders.mockResolvedValue({ items: [] })
const w = await mountItem({ id: 'f1', has_children: true })
await w.find('button').trigger('click')
await w.vm.$nextTick()
// After loading empty children, button should be gone
expect(w.find('button').exists()).toBe(false)
})
// ── Depth indentation ────────────────────────────────────────────────────
it('applies correct left padding based on depth', async () => {
setActivePinia(createPinia())
const router = makeRouter('/')
await router.push('/')
await router.isReady()
const w = mount(FolderTreeItem, {
props: { folder: makeFolder(), depth: 3 },
global: { plugins: [router] },
})
const row = w.find('.flex.items-center')
expect(row.attributes('style')).toContain('padding-left: 36px')
})
})
+43 -41
View File
@@ -2,24 +2,12 @@
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0"> <aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0">
<!-- Logo --> <!-- Logo -->
<div class="px-6 py-5 border-b border-gray-100"> <div class="px-6 py-5 border-b border-gray-100">
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocScanner</h1> <h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocuVault</h1>
<p class="text-xs text-gray-400 mt-0.5">AI Document Classifier</p> <p class="text-xs text-gray-400 mt-0.5">Document Manager</p>
</div> </div>
<!-- Nav --> <!-- Nav -->
<nav class="flex-1 px-3 py-4 overflow-y-auto"> <nav class="flex-1 px-3 py-4 overflow-y-auto">
<router-link
to="/"
class="nav-link"
:class="{ 'nav-link-active': $route.path === '/' }"
>
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</router-link>
<router-link <router-link
to="/topics" to="/topics"
class="nav-link" class="nav-link"
@@ -32,7 +20,7 @@
All Topics All Topics
</router-link> </router-link>
<!-- Shared with me entry --> <!-- Shared with me -->
<router-link <router-link
to="/shared" to="/shared"
class="nav-link" class="nav-link"
@@ -53,20 +41,32 @@
</span> </span>
</router-link> </router-link>
<!-- Folders section --> <!-- Folders root + collapsible tree -->
<div class="mt-3"> <div class="mt-3">
<div class="flex items-center justify-between px-3 mb-1"> <!-- "Folders" is the root entry clicking navigates to the root folder view -->
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Folders</p> <div class="flex items-center justify-between">
<router-link
to="/"
class="nav-link flex-1"
:class="{ 'nav-link-active': $route.path === '/' || $route.path.startsWith('/folders/') }"
>
<svg class="w-4 h-4 mr-2 shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
Folders
</router-link>
<button <button
@click="startNewFolder" @click="startNewFolder"
class="text-xs text-indigo-600 hover:underline" class="text-xs text-indigo-600 hover:underline shrink-0 mr-1"
title="New root folder"
> >
New folder New
</button> </button>
</div> </div>
<!-- New folder inline input --> <!-- Inline new root folder input -->
<div v-if="showNewFolderInput" class="px-3 mb-2"> <div v-if="showNewFolderInput" class="px-3 mb-2 mt-1">
<input <input
v-model="newFolderName" v-model="newFolderName"
type="text" type="text"
@@ -79,21 +79,16 @@
<p v-if="newFolderError" class="text-red-500 text-xs mt-1">{{ newFolderError }}</p> <p v-if="newFolderError" class="text-red-500 text-xs mt-1">{{ newFolderError }}</p>
</div> </div>
<!-- Folder list --> <!-- Sub-folders tree (indented under Folders) -->
<div v-if="foldersStore.loading && foldersStore.folders.length === 0" class="px-3 py-1 text-xs text-gray-400">Loading</div> <div v-if="loadingRoots" class="pl-7 py-1 text-xs text-gray-400">Loading</div>
<router-link <div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput"
v-for="folder in foldersStore.folders" class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>
<FolderTreeItem
v-for="folder in foldersStore.rootFolders"
:key="folder.id" :key="folder.id"
:to="`/folders/${folder.id}`" :folder="folder"
class="nav-link text-sm" :depth="1"
:class="{ 'nav-link-active': $route.params.folderId === folder.id || $route.params.folderId === String(folder.id) }" />
>
<svg class="w-4 h-4 mr-2 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<span class="truncate flex-1">{{ folder.name }}</span>
</router-link>
</div> </div>
<!-- Topics list --> <!-- Topics list -->
@@ -118,7 +113,7 @@
</div> </div>
</nav> </nav>
<!-- Quota bar (between topics nav and settings footer, UI-SPEC Phase 3) --> <!-- Quota bar -->
<QuotaBar /> <QuotaBar />
<!-- Settings + Admin link --> <!-- Settings + Admin link -->
@@ -172,27 +167,34 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter } from 'vue-router'
import { useTopicsStore } from '../../stores/topics.js' import { useTopicsStore } from '../../stores/topics.js'
import { useAuthStore } from '../../stores/auth.js' import { useAuthStore } from '../../stores/auth.js'
import { useFoldersStore } from '../../stores/folders.js' import { useFoldersStore } from '../../stores/folders.js'
import QuotaBar from './QuotaBar.vue' import QuotaBar from './QuotaBar.vue'
import FolderTreeItem from '../folders/FolderTreeItem.vue'
import * as api from '../../api/client.js' import * as api from '../../api/client.js'
const topicsStore = useTopicsStore() const topicsStore = useTopicsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const foldersStore = useFoldersStore() const foldersStore = useFoldersStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
const sharedCount = ref(0) const sharedCount = ref(0)
const showNewFolderInput = ref(false) const showNewFolderInput = ref(false)
const newFolderName = ref('') const newFolderName = ref('')
const newFolderError = ref('') const newFolderError = ref('')
const loadingRoots = ref(true)
watch(() => foldersStore.treeVersion, () => foldersStore.fetchRootFolders())
onMounted(async () => { onMounted(async () => {
await foldersStore.fetchFolders(null) try {
await foldersStore.fetchRootFolders()
} finally {
loadingRoots.value = false
}
try { try {
const data = await api.getSharedWithMe() const data = await api.getSharedWithMe()
const items = Array.isArray(data) ? data : (data.items ?? []) const items = Array.isArray(data) ? data : (data.items ?? [])
+3
View File
@@ -7,4 +7,7 @@ import './style.css'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
// Wait for the initial navigation guard (token refresh) to complete before mounting
// so onMounted hooks in App.vue and child components have a valid access token.
await router.isReady()
app.mount('#app') app.mount('#app')
+4 -4
View File
@@ -1,13 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js' import { useAuthStore } from '../stores/auth.js'
import HomeView from '../views/HomeView.vue' import FileManagerView from '../views/FileManagerView.vue'
import TopicsView from '../views/TopicsView.vue' import TopicsView from '../views/TopicsView.vue'
import DocumentView from '../views/DocumentView.vue' import DocumentView from '../views/DocumentView.vue'
import SettingsView from '../views/SettingsView.vue' import SettingsView from '../views/SettingsView.vue'
const routes = [ const routes = [
// Existing routes // File manager is the home — handles both root and folder views
{ path: '/', component: HomeView }, { path: '/', component: FileManagerView },
{ path: '/topics', component: TopicsView }, { path: '/topics', component: TopicsView },
{ path: '/topics/:name', component: TopicsView }, { path: '/topics/:name', component: TopicsView },
{ path: '/document/:id', component: DocumentView }, { path: '/document/:id', component: DocumentView },
@@ -43,7 +43,7 @@ const routes = [
{ {
path: '/folders/:folderId', path: '/folders/:folderId',
name: 'folder', name: 'folder',
component: () => import('../views/FolderView.vue'), component: FileManagerView,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
@@ -0,0 +1,220 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFoldersStore } from '../folders.js'
// ── API mock ──────────────────────────────────────────────────────────────────
const mockListFolders = vi.fn()
const mockCreateFolder = vi.fn()
const mockGetFolder = vi.fn()
const mockRenameFolder = vi.fn()
const mockDeleteFolder = vi.fn()
vi.mock('../../api/client.js', () => ({
listFolders: (...a) => mockListFolders(...a),
createFolder: (...a) => mockCreateFolder(...a),
getFolder: (...a) => mockGetFolder(...a),
renameFolder: (...a) => mockRenameFolder(...a),
deleteFolder: (...a) => mockDeleteFolder(...a),
moveDocument: vi.fn(),
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeFolder(overrides = {}) {
return {
id: overrides.id ?? 'folder-1',
name: overrides.name ?? 'Test',
parent_id: overrides.parent_id ?? null,
has_children: overrides.has_children ?? false,
created_at: '2026-01-01T00:00:00Z',
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('foldersStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
// ── fetchFolders ─────────────────────────────────────────────────────────
it('fetchFolders(null) sets folders and rootFolders', async () => {
const roots = [makeFolder({ id: 'r1', name: 'Root1' })]
mockListFolders.mockResolvedValue({ items: roots })
const store = useFoldersStore()
await store.fetchFolders(null)
expect(store.folders).toEqual(roots)
expect(store.rootFolders).toEqual(roots)
})
it('fetchFolders(parentId) sets folders but NOT rootFolders', async () => {
const children = [makeFolder({ id: 'c1', name: 'Child', parent_id: 'parent-1' })]
mockListFolders.mockResolvedValue({ items: children })
const store = useFoldersStore()
store.rootFolders = [makeFolder({ id: 'r1' })] // pre-populate rootFolders
await store.fetchFolders('parent-1')
expect(store.folders).toEqual(children)
expect(store.rootFolders).toEqual([makeFolder({ id: 'r1' })]) // unchanged
})
it('fetchRootFolders sets rootFolders without touching folders', async () => {
const roots = [makeFolder({ id: 'r1' })]
mockListFolders.mockResolvedValue({ items: roots })
const store = useFoldersStore()
store.folders = [makeFolder({ id: 'sub1', parent_id: 'r1' })] // simulate being inside a folder
await store.fetchRootFolders()
expect(store.rootFolders).toEqual(roots)
expect(store.folders[0].id).toBe('sub1') // unchanged
})
// ── createFolder ─────────────────────────────────────────────────────────
it('createFolder(name, null) adds to both folders and rootFolders', async () => {
const newFolder = makeFolder({ id: 'new-1', name: 'NewRoot' })
mockCreateFolder.mockResolvedValue(newFolder)
const store = useFoldersStore()
await store.createFolder('NewRoot', null)
expect(store.folders.some(f => f.id === 'new-1')).toBe(true)
expect(store.rootFolders.some(f => f.id === 'new-1')).toBe(true)
})
it('createFolder(name, parentId) adds to folders but NOT rootFolders', async () => {
const newFolder = makeFolder({ id: 'sub-1', name: 'SubFolder', parent_id: 'parent-1' })
mockCreateFolder.mockResolvedValue(newFolder)
const store = useFoldersStore()
await store.createFolder('SubFolder', 'parent-1')
expect(store.folders.some(f => f.id === 'sub-1')).toBe(true)
expect(store.rootFolders.some(f => f.id === 'sub-1')).toBe(false)
})
it('createFolder bumps treeVersion', async () => {
mockCreateFolder.mockResolvedValue(makeFolder())
const store = useFoldersStore()
const before = store.treeVersion
await store.createFolder('Test', null)
expect(store.treeVersion).toBe(before + 1)
})
it('createFolder throws on API error and propagates', async () => {
mockCreateFolder.mockRejectedValue(new Error('409 Conflict'))
const store = useFoldersStore()
await expect(store.createFolder('Dup', null)).rejects.toThrow('409 Conflict')
})
// ── renameFolder ─────────────────────────────────────────────────────────
it('renameFolder updates folder in both folders and rootFolders', async () => {
const original = makeFolder({ id: 'f1', name: 'OldName' })
const updated = { ...original, name: 'NewName' }
mockRenameFolder.mockResolvedValue(updated)
const store = useFoldersStore()
store.folders = [original]
store.rootFolders = [original]
await store.renameFolder('f1', 'NewName')
expect(store.folders[0].name).toBe('NewName')
expect(store.rootFolders[0].name).toBe('NewName')
})
it('renameFolder bumps treeVersion', async () => {
const folder = makeFolder({ id: 'f1' })
mockRenameFolder.mockResolvedValue({ ...folder, name: 'New' })
const store = useFoldersStore()
store.folders = [folder]
const before = store.treeVersion
await store.renameFolder('f1', 'New')
expect(store.treeVersion).toBe(before + 1)
})
// ── deleteFolder ─────────────────────────────────────────────────────────
it('deleteFolder removes from both folders and rootFolders', async () => {
mockDeleteFolder.mockResolvedValue(null)
const store = useFoldersStore()
const folder = makeFolder({ id: 'del-1' })
store.folders = [folder]
store.rootFolders = [folder]
await store.deleteFolder('del-1')
expect(store.folders.some(f => f.id === 'del-1')).toBe(false)
expect(store.rootFolders.some(f => f.id === 'del-1')).toBe(false)
})
it('deleteFolder bumps treeVersion', async () => {
mockDeleteFolder.mockResolvedValue(null)
const store = useFoldersStore()
store.folders = [makeFolder({ id: 'del-1' })]
const before = store.treeVersion
await store.deleteFolder('del-1')
expect(store.treeVersion).toBe(before + 1)
})
// ── navigateTo ───────────────────────────────────────────────────────────
it('navigateTo(null) clears folders and breadcrumb', async () => {
const store = useFoldersStore()
store.folders = [makeFolder()]
store.breadcrumb = [{ id: 'x', name: 'X' }]
await store.navigateTo(null)
expect(store.folders).toEqual([])
expect(store.breadcrumb).toEqual([])
})
it('navigateTo(id) sets breadcrumb from API', async () => {
const crumbs = [{ id: 'r1', name: 'Root' }, { id: 'f1', name: 'Test' }]
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: crumbs })
const store = useFoldersStore()
await store.navigateTo('f1')
expect(store.breadcrumb).toEqual(crumbs)
})
it('navigateTo(id) sets breadcrumb to [] on API error', async () => {
mockGetFolder.mockRejectedValue(new Error('404'))
const store = useFoldersStore()
await store.navigateTo('bad-id')
expect(store.breadcrumb).toEqual([])
})
it('navigateTo sets currentFolderId', async () => {
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] })
const store = useFoldersStore()
await store.navigateTo('f1')
expect(store.currentFolderId).toBe('f1')
})
})
+19 -5
View File
@@ -54,16 +54,17 @@ export const useDocumentsStore = defineStore('documents', () => {
} }
/** /**
* Three-step upload: * Three-step upload with optional folder placement:
* Step 1: POST /api/documents/upload-url → {upload_url, document_id} (0% → 5%) * Step 1: POST /api/documents/upload-url → {upload_url, document_id} (0% → 5%)
* Step 2: XHR PUT bytes directly to MinIO presigned URL (5% → 90%) * Step 2: XHR PUT bytes directly to MinIO presigned URL (5% → 90%)
* Step 3: POST /api/documents/{id}/confirm → {id, size_bytes, used_bytes, status} (92% → 100%) * Step 3: POST /api/documents/{id}/confirm → {id, size_bytes, used_bytes, status} (92% → 96%)
* Step 3b: PATCH /api/documents/{id}/folder if folderId is non-null (96% → 100%)
* *
* On 413 quota exceeded: err.payload = {used_bytes, limit_bytes, rejected_bytes} from confirm step. * On 413 quota exceeded: err.payload = {used_bytes, limit_bytes, rejected_bytes} from confirm step.
* On success: authStore.fetchQuota() is called to refresh sidebar quota bar (STORE-04). * On success: authStore.fetchQuota() is called to refresh sidebar quota bar (STORE-04).
* Returns { rowKey, doc } on success so callers can clear the progress entry. * Returns { rowKey, doc } on success so callers can clear the progress entry.
*/ */
async function upload(file, autoClassify = true) { async function upload(file, autoClassify = true, folderId = null) {
const authStore = useAuthStore() const authStore = useAuthStore()
// Composite key prevents collision when same filename uploaded twice (T-03-25) // Composite key prevents collision when same filename uploaded twice (T-03-25)
const rowKey = `${file.name}__${Date.now()}` const rowKey = `${file.name}__${Date.now()}`
@@ -83,8 +84,14 @@ export const useDocumentsStore = defineStore('documents', () => {
}) })
uploadProgress.value[rowKey] = 92 uploadProgress.value[rowKey] = 92
// Step 3: confirm (UI-SPEC: 92% → 100%, status "Processing…" → "Done — classifying…") // Step 3: confirm (UI-SPEC: 92% → 96%, status "Processing…" → "Done — classifying…")
const doc = await api.confirmUpload(document_id) const doc = await api.confirmUpload(document_id)
uploadProgress.value[rowKey] = 96
// Step 3b: move to target folder if the upload was initiated inside a folder
if (folderId != null) {
await api.moveDocument(doc.id, folderId)
}
uploadProgress.value[rowKey] = 100 uploadProgress.value[rowKey] = 100
documents.value.unshift({ documents.value.unshift({
@@ -94,6 +101,7 @@ export const useDocumentsStore = defineStore('documents', () => {
mime_type: file.type, mime_type: file.type,
size_bytes: doc.size_bytes, size_bytes: doc.size_bytes,
topics: [], topics: [],
folder_id: folderId,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
classified_at: null, classified_at: null,
}) })
@@ -142,6 +150,12 @@ export const useDocumentsStore = defineStore('documents', () => {
}, 300) }, 300)
}) })
async function moveToFolder(docId, folderId) {
await api.moveDocument(docId, folderId)
documents.value = documents.value.filter(d => d.id !== docId)
total.value = Math.max(0, total.value - 1)
}
async function shareDocument(docId, recipientHandle) { async function shareDocument(docId, recipientHandle) {
try { return await api.createShare(docId, recipientHandle) } catch (e) { throw e } try { return await api.createShare(docId, recipientHandle) } catch (e) { throw e }
} }
@@ -154,5 +168,5 @@ export const useDocumentsStore = defineStore('documents', () => {
try { return await api.listShares(docId) } catch (e) { throw e } try { return await api.listShares(docId) } catch (e) { throw e }
} }
return { documents, total, loading, error, uploadProgress, currentFolderId, searchQuery, sortField, sortOrder, fetchDocuments, upload, remove, reclassify, shareDocument, revokeShare, listShares } return { documents, total, loading, error, uploadProgress, currentFolderId, searchQuery, sortField, sortOrder, fetchDocuments, upload, remove, reclassify, moveToFolder, shareDocument, revokeShare, listShares }
}) })
+21 -2
View File
@@ -4,17 +4,21 @@ import * as api from '../api/client.js'
export const useFoldersStore = defineStore('folders', () => { export const useFoldersStore = defineStore('folders', () => {
const folders = ref([]) const folders = ref([])
const rootFolders = ref([]) // root-level folders for sidebar tree and folder pickers
const currentFolderId = ref(null) const currentFolderId = ref(null)
const breadcrumb = ref([]) const breadcrumb = ref([])
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const treeVersion = ref(0) // bumped on any mutation so sidebar tree can react
async function fetchFolders(parentId = null) { async function fetchFolders(parentId = null) {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const data = await api.listFolders(parentId) const data = await api.listFolders(parentId)
folders.value = data.items ?? data const list = data.items ?? data
folders.value = list
if (parentId === null) rootFolders.value = list
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to load folders' error.value = e.message || 'Failed to load folders'
} finally { } finally {
@@ -22,12 +26,21 @@ export const useFoldersStore = defineStore('folders', () => {
} }
} }
async function fetchRootFolders() {
try {
const data = await api.listFolders(null)
rootFolders.value = data.items ?? data
} catch {}
}
async function createFolder(name, parentId = null) { async function createFolder(name, parentId = null) {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const folder = await api.createFolder(name, parentId) const folder = await api.createFolder(name, parentId)
folders.value.push(folder) folders.value.push(folder)
if (parentId === null) rootFolders.value.push(folder)
treeVersion.value++
return folder return folder
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to create folder' error.value = e.message || 'Failed to create folder'
@@ -44,6 +57,9 @@ export const useFoldersStore = defineStore('folders', () => {
const updated = await api.renameFolder(folderId, name) const updated = await api.renameFolder(folderId, name)
const idx = folders.value.findIndex(f => f.id === folderId) const idx = folders.value.findIndex(f => f.id === folderId)
if (idx !== -1) folders.value[idx] = updated if (idx !== -1) folders.value[idx] = updated
const rootIdx = rootFolders.value.findIndex(f => f.id === folderId)
if (rootIdx !== -1) rootFolders.value[rootIdx] = updated
treeVersion.value++
return updated return updated
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to rename folder' error.value = e.message || 'Failed to rename folder'
@@ -59,6 +75,8 @@ export const useFoldersStore = defineStore('folders', () => {
try { try {
await api.deleteFolder(folderId) await api.deleteFolder(folderId)
folders.value = folders.value.filter(f => f.id !== folderId) folders.value = folders.value.filter(f => f.id !== folderId)
rootFolders.value = rootFolders.value.filter(f => f.id !== folderId)
treeVersion.value++
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to delete folder' error.value = e.message || 'Failed to delete folder'
throw e throw e
@@ -78,8 +96,9 @@ export const useFoldersStore = defineStore('folders', () => {
} }
} else { } else {
breadcrumb.value = [] breadcrumb.value = []
folders.value = [] // clear stale subfolder list when returning to home
} }
} }
return { folders, currentFolderId, breadcrumb, loading, error, fetchFolders, createFolder, renameFolder, deleteFolder, navigateTo } return { folders, rootFolders, currentFolderId, breadcrumb, loading, error, treeVersion, fetchFolders, fetchRootFolders, createFolder, renameFolder, deleteFolder, navigateTo }
}) })
+450
View File
@@ -0,0 +1,450 @@
<template>
<div class="flex flex-col h-full">
<!-- Sticky toolbar -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<div class="px-6 py-3 flex items-center gap-3 flex-wrap">
<FolderBreadcrumb
:segments="foldersStore.breadcrumb"
@navigate="handleBreadcrumbNavigate"
/>
<div class="ml-auto flex items-center gap-2 shrink-0">
<SearchBar v-if="currentFolderId" v-model="docsStore.searchQuery" />
<SortControls
v-if="currentFolderId"
:sort="docsStore.sortField"
:order="docsStore.sortOrder"
@change="handleSortChange"
/>
<button
@click="startNewFolder"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New folder
</button>
</div>
</div>
</div>
<!-- Folder view -->
<div class="flex-1 overflow-y-auto flex flex-col">
<!-- Upload zone (always at top of folder view) -->
<div class="px-6 pt-5 pb-3">
<DropZone @files-selected="onFilesSelected" />
<UploadProgress :items="uploadQueue" />
</div>
<!-- Column headers -->
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none">
<span></span>
<span>Name</span>
<span class="text-right hidden md:block">Size</span>
<span class="text-right hidden sm:block">Modified</span>
<span></span>
</div>
<!-- New-folder inline row -->
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</div>
<div class="flex items-center gap-2 col-span-4">
<input
ref="newFolderInputRef"
v-model="newFolderName"
type="text"
placeholder="Folder name"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitNewFolder"
@keydown.escape="cancelNewFolder"
/>
<button @click="submitNewFolder" class="text-sm text-indigo-600 hover:underline font-medium">Save</button>
<button @click="cancelNewFolder" class="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
<p v-if="newFolderError" class="text-xs text-red-500">{{ newFolderError }}</p>
</div>
</div>
<!-- Item list -->
<div class="mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
<!-- Folder rows -------------------------------------------------- -->
<div
v-for="folder in foldersStore.folders"
:key="`f-${folder.id}`"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
:class="{
'bg-amber-50 ring-2 ring-inset ring-amber-300': docDragOverFolderId === folder.id,
'bg-gray-50': renaming === folder.id,
}"
@click="renaming === folder.id ? null : navigateToFolder(folder.id)"
@dragover.prevent="onFolderDragOver(folder.id, $event)"
@dragleave="onFolderDragLeave"
@drop.prevent="onDropDocOnFolder(folder.id)"
>
<!-- Folder icon -->
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</div>
<!-- Name / rename input -->
<div>
<input
v-if="renaming === folder.id"
v-model="renameValue"
class="border border-indigo-300 rounded-lg px-2 py-0.5 text-sm w-full max-w-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitRename(folder.id)"
@keydown.escape="cancelRename"
@vue:mounted="e => e.el?.focus()"
/>
<span v-else class="text-sm font-medium text-gray-900 truncate block">{{ folder.name }}</span>
</div>
<span class="text-right text-xs text-gray-400 hidden md:block"></span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
<!-- Hover actions -->
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<button @click.stop="startRename(folder)" title="Rename"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click.stop="folderToDelete = folder" title="Delete folder"
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Document rows ------------------------------------------------ -->
<div
v-for="doc in docsStore.documents"
:key="`d-${doc.id}`"
draggable="true"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
:class="{ 'opacity-50': docDragging?.id === doc.id }"
@click="$router.push(`/document/${doc.id}`)"
@dragstart="onDocDragStart(doc, $event)"
@dragend="docDragging = null; docDragOverFolderId = null"
>
<!-- File icon -->
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-4 h-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<!-- Name + topics -->
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ doc.original_name }}</p>
<div v-if="doc.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap">
<TopicBadge v-for="t in doc.topics.slice(0, 3)" :key="t" :name="t" :color="topicColor(t)" />
</div>
</div>
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(doc.size_bytes) }}</span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(doc.created_at) }}</span>
<!-- Hover actions -->
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<!-- Share -->
<button @click.stop="shareDoc = doc" title="Share"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<!-- Move -->
<div class="relative">
<button
@click.stop="folderPickerDocId = folderPickerDocId === doc.id ? null : doc.id"
title="Move to folder"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
<div
v-if="folderPickerDocId === doc.id"
class="absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-xl shadow-lg z-20 py-1"
@click.stop
>
<button class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
@click.stop="doMove(doc.id, null)">Root (no folder)</button>
<button
v-for="f in foldersStore.rootFolders"
:key="f.id"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 truncate"
@click.stop="doMove(doc.id, f.id)"
>{{ f.name }}</button>
<p v-if="!foldersStore.rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
</div>
</div>
<!-- Delete -->
<button @click.stop="doDeleteDoc(doc.id)" title="Delete"
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Empty state (no search active) -->
<div
v-if="!docsStore.loading && !foldersStore.loading && foldersStore.folders.length === 0 && docsStore.documents.length === 0 && !showNewFolderInput"
class="px-4 py-10 text-center text-gray-300"
>
<p class="text-gray-400 text-sm">{{ currentFolderId ? 'This folder is empty' : 'No folders yet' }}</p>
<p class="text-xs mt-1">{{ currentFolderId ? 'Upload files above or create a sub-folder' : 'Create a folder to get started' }}</p>
</div>
<!-- No results for search -->
<div
v-else-if="docsStore.searchQuery && docsStore.documents.length === 0 && foldersStore.folders.length === 0"
class="px-4 py-10 text-center text-sm text-gray-400"
>
No items match "{{ docsStore.searchQuery }}".
</div>
<!-- Loading -->
<div v-if="docsStore.loading || foldersStore.loading" class="py-6 text-center text-sm text-gray-400">
Loading
</div>
</div>
</div>
<!-- Modals -->
<FolderDeleteModal
v-if="folderToDelete"
:folder="folderToDelete"
@confirm="confirmDeleteFolder"
@cancel="folderToDelete = null"
/>
<ShareModal
v-if="shareDoc"
:doc="shareDoc"
@close="shareDoc = null"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFoldersStore } from '../stores/folders.js'
import { useDocumentsStore } from '../stores/documents.js'
import { useTopicsStore } from '../stores/topics.js'
import FolderBreadcrumb from '../components/folders/FolderBreadcrumb.vue'
import FolderDeleteModal from '../components/folders/FolderDeleteModal.vue'
import SearchBar from '../components/documents/SearchBar.vue'
import SortControls from '../components/documents/SortControls.vue'
import TopicBadge from '../components/topics/TopicBadge.vue'
import UploadProgress from '../components/upload/UploadProgress.vue'
import DropZone from '../components/upload/DropZone.vue'
import ShareModal from '../components/sharing/ShareModal.vue'
const route = useRoute()
const router = useRouter()
const foldersStore = useFoldersStore()
const docsStore = useDocumentsStore()
const topicsStore = useTopicsStore()
// ── Navigation ────────────────────────────────────────────────────────────────
const currentFolderId = computed(() => route.params.folderId ?? null)
async function loadFolder(folderId) {
if (folderId === null) {
foldersStore.navigateTo(null)
await foldersStore.fetchFolders(null)
return
}
await foldersStore.navigateTo(folderId)
await Promise.all([
foldersStore.fetchFolders(folderId),
docsStore.fetchDocuments({ folderId, sort: docsStore.sortField, order: docsStore.sortOrder }),
])
docsStore.currentFolderId = folderId
}
onMounted(() => loadFolder(currentFolderId.value))
watch(currentFolderId, (id) => loadFolder(id))
function navigateToFolder(id) { router.push(`/folders/${id}`) }
function handleBreadcrumbNavigate(id) {
if (id == null) router.push('/')
else router.push(`/folders/${id}`)
}
function handleSortChange({ sort, order }) {
docsStore.sortField = sort
docsStore.sortOrder = order
docsStore.fetchDocuments({ folderId: currentFolderId.value, sort, order })
}
// ── Upload ────────────────────────────────────────────────────────────────────
const uploadQueue = ref([])
async function onFilesSelected({ files, autoClassify }) {
const folderId = currentFolderId.value
const promises = files.map(file => {
const item = reactive({ name: file.name, done: false, error: null, quotaError: null, topics: null })
uploadQueue.value.unshift(item)
return docsStore.upload(file, autoClassify, folderId)
.then(({ doc }) => { item.done = true; item.topics = doc.topics ?? [] })
.catch(e => {
if (e.status === 413 && e.payload) item.quotaError = e.payload
else item.error = e.message
})
})
await Promise.allSettled(promises)
await topicsStore.fetchTopics()
}
// ── Drag-and-drop: move documents onto folders ────────────────────────────────
const docDragging = ref(null) // doc object being dragged
const docDragOverFolderId = ref(null) // folder.id the cursor is over
function onDocDragStart(doc, e) {
docDragging.value = doc
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', doc.id)
}
function onFolderDragOver(folderId, e) {
if (!docDragging.value) return
e.preventDefault()
docDragOverFolderId.value = folderId
}
function onFolderDragLeave() {
docDragOverFolderId.value = null
}
async function onDropDocOnFolder(folderId) {
if (!docDragging.value) return
const docId = docDragging.value.id
docDragging.value = null
docDragOverFolderId.value = null
try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
}
// ── New folder ────────────────────────────────────────────────────────────────
const showNewFolderInput = ref(false)
const newFolderName = ref('')
const newFolderError = ref('')
const newFolderInputRef = ref(null)
function startNewFolder() {
newFolderName.value = ''
newFolderError.value = ''
showNewFolderInput.value = true
nextTick(() => newFolderInputRef.value?.focus())
}
function cancelNewFolder() { showNewFolderInput.value = false; newFolderError.value = '' }
async function submitNewFolder() {
const name = newFolderName.value.trim()
if (!name) { newFolderError.value = 'Folder name cannot be empty.'; return }
try {
await foldersStore.createFolder(name, currentFolderId.value)
showNewFolderInput.value = false
newFolderError.value = ''
} catch (e) {
newFolderError.value = e.message || 'Failed to create folder.'
}
}
// ── Rename folder ─────────────────────────────────────────────────────────────
const renaming = ref(null)
const renameValue = ref('')
function startRename(folder) {
renaming.value = folder.id
renameValue.value = folder.name
}
function cancelRename() { renaming.value = null }
async function submitRename(folderId) {
const name = renameValue.value.trim()
if (!name) { cancelRename(); return }
try { await foldersStore.renameFolder(folderId, name) }
finally { renaming.value = null }
}
// ── Delete folder ─────────────────────────────────────────────────────────────
const folderToDelete = ref(null)
async function confirmDeleteFolder() {
if (!folderToDelete.value) return
try { await foldersStore.deleteFolder(folderToDelete.value.id) }
finally { folderToDelete.value = null }
}
// ── Document actions ──────────────────────────────────────────────────────────
const shareDoc = ref(null)
const folderPickerDocId = ref(null)
async function doMove(docId, folderId) {
folderPickerDocId.value = null
try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
}
async function doDeleteDoc(docId) {
try { await docsStore.remove(docId) } catch (e) { console.error(e.message) }
}
function onOutsideClick(e) {
if (!e.target.closest('.relative')) folderPickerDocId.value = null
}
onMounted(() => document.addEventListener('click', onOutsideClick))
onUnmounted(() => document.removeEventListener('click', onOutsideClick))
// ── Helpers ───────────────────────────────────────────────────────────────────
function topicColor(name) {
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
}
function formatDate(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function formatSize(bytes) {
if (!bytes) return '—'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
</script>
@@ -0,0 +1,444 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import FileManagerView from '../FileManagerView.vue'
// ── API mock ──────────────────────────────────────────────────────────────────
const mockListFolders = vi.fn()
const mockListDocuments = vi.fn()
const mockGetFolder = vi.fn()
const mockCreateFolder = vi.fn()
const mockRenameFolder = vi.fn()
const mockDeleteFolder = vi.fn()
const mockMoveDocument = vi.fn()
vi.mock('../../api/client.js', () => ({
listFolders: (...a) => mockListFolders(...a),
listDocuments: (...a) => mockListDocuments(...a),
getFolder: (...a) => mockGetFolder(...a),
createFolder: (...a) => mockCreateFolder(...a),
renameFolder: (...a) => mockRenameFolder(...a),
deleteFolder: (...a) => mockDeleteFolder(...a),
moveDocument: (...a) => mockMoveDocument(...a),
deleteDocument: vi.fn().mockResolvedValue(null),
getSharedWithMe: vi.fn().mockResolvedValue([]),
listTopics: vi.fn().mockResolvedValue([]),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
// Stub heavy child components so we only test FileManagerView logic
vi.mock('../../components/folders/FolderBreadcrumb.vue', () => ({
default: { template: '<nav><slot/></nav>', props: ['segments'], emits: ['navigate'] },
}))
vi.mock('../../components/upload/DropZone.vue', () => ({
default: { template: '<div class="dropzone"/>', emits: ['files-selected'] },
}))
vi.mock('../../components/upload/UploadProgress.vue', () => ({
default: { template: '<div/>', props: ['items'] },
}))
vi.mock('../../components/folders/FolderDeleteModal.vue', () => ({
default: { template: '<div/>', props: ['folder'], emits: ['confirm', 'cancel'] },
}))
vi.mock('../../components/sharing/ShareModal.vue', () => ({
default: { template: '<div/>', props: ['doc'], emits: ['close'] },
}))
vi.mock('../../components/documents/SearchBar.vue', () => ({
default: { template: '<input/>', props: ['modelValue'] },
}))
vi.mock('../../components/documents/SortControls.vue', () => ({
default: { template: '<div/>', props: ['sort', 'order'], emits: ['change'] },
}))
vi.mock('../../components/topics/TopicBadge.vue', () => ({
default: { template: '<span/>', props: ['name', 'color'] },
}))
vi.mock('../../stores/auth.js', () => ({
useAuthStore: () => ({
user: { email: 'test@example.com', role: 'user' },
accessToken: 'fake-token',
fetchQuota: vi.fn().mockResolvedValue(null),
}),
}))
vi.mock('../../stores/topics.js', () => ({
useTopicsStore: () => ({
topics: [],
loading: false,
fetchTopics: vi.fn().mockResolvedValue(null),
}),
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeFolder(overrides = {}) {
return {
id: overrides.id ?? 'f1',
name: overrides.name ?? 'TestFolder',
parent_id: overrides.parent_id ?? null,
has_children: overrides.has_children ?? false,
created_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
function makeDoc(overrides = {}) {
return {
id: overrides.id ?? 'd1',
original_name: overrides.original_name ?? 'test.pdf',
filename: 'test.pdf',
mime_type: 'application/pdf',
size_bytes: 1024,
topics: [],
folder_id: overrides.folder_id ?? 'f1',
created_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: FileManagerView },
{ path: '/folders/:folderId', component: FileManagerView },
{ path: '/document/:id', component: { template: '<div/>' } },
],
})
}
async function mountView(path = '/') {
setActivePinia(createPinia())
const router = makeRouter()
await router.push(path)
await router.isReady()
const w = mount(FileManagerView, {
global: {
plugins: [router],
stubs: { QuotaBar: true, AppSpinner: true },
},
})
await flushPromises()
return { w, router }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('FileManagerView — root navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] })
})
it('at root (/), fetches root folders via listFolders(null)', async () => {
const roots = [makeFolder({ id: 'r1', name: 'Root1' })]
mockListFolders.mockResolvedValue({ items: roots })
await mountView('/')
expect(mockListFolders).toHaveBeenCalledWith(null)
})
it('at root, renders root folders in the item list', async () => {
mockListFolders.mockResolvedValue({ items: [makeFolder({ id: 'r1', name: 'MyFolder' })] })
const { w } = await mountView('/')
expect(w.text()).toContain('MyFolder')
})
it('root view does NOT call listDocuments', async () => {
await mountView('/')
expect(mockListDocuments).not.toHaveBeenCalled()
})
})
describe('FileManagerView — folder navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [{ id: 'f1', name: 'Test' }] })
})
it('at /folders/:id, fetches that folder\'s children', async () => {
const children = [makeFolder({ id: 'c1', name: 'Child', parent_id: 'f1' })]
mockListFolders.mockResolvedValue({ items: children })
await mountView('/folders/f1')
expect(mockListFolders).toHaveBeenCalledWith('f1')
})
it('at /folders/:id, fetches documents for that folder', async () => {
await mountView('/folders/f1')
expect(mockListDocuments).toHaveBeenCalledWith(
expect.objectContaining({ folderId: 'f1' })
)
})
it('renders subfolder rows', async () => {
mockListFolders.mockResolvedValue({
items: [makeFolder({ id: 'sub1', name: 'SubFolder', parent_id: 'f1' })],
})
const { w } = await mountView('/folders/f1')
expect(w.text()).toContain('SubFolder')
})
it('renders document rows', async () => {
mockListDocuments.mockResolvedValue({
items: [makeDoc({ id: 'd1', original_name: 'report.pdf' })],
total: 1,
})
const { w } = await mountView('/folders/f1')
expect(w.text()).toContain('report.pdf')
})
})
describe('FileManagerView — folder row click navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] })
})
async function setupWithFolders(folders) {
mockListFolders.mockResolvedValue({ items: folders })
const { w, router } = await mountView('/')
return { w, router }
}
it('clicking folder ICON cell navigates to /folders/:id', async () => {
const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })])
// Column 1 (icon cell) — the amber div
const iconCell = w.find('.bg-amber-50.rounded-lg')
await iconCell.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/target-1')
})
it('clicking folder NAME text navigates to /folders/:id', async () => {
// This tests the bug: @click.stop on the name wrapper div prevents navigation
const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })])
// Column 2 (name cell) — the span with the folder name
const nameSpan = w.findAll('span').find(s => s.text() === 'Clickme')
expect(nameSpan).toBeTruthy()
await nameSpan.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/target-1')
})
it('clicking folder DATE cell navigates to /folders/:id', async () => {
const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })])
// find the date cells (text-gray-400 text-xs spans)
const dateCells = w.findAll('.text-right.text-xs.text-gray-400')
// Click the first date cell (the "—" size or actual date)
if (dateCells.length > 0) {
await dateCells[0].trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/target-1')
}
})
it('clicking rename button does NOT navigate', async () => {
const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })])
// Find rename button (pencil icon button in hover actions)
const actionBtns = w.findAll('.opacity-0.group-hover\\:opacity-100 button')
if (actionBtns.length > 0) {
await actionBtns[0].trigger('click')
await flushPromises()
// Should still be at root
expect(router.currentRoute.value.path).toBe('/')
}
})
})
describe('FileManagerView — subfolder creation does not break navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({
id: 'parent-1', name: 'Test',
breadcrumb: [{ id: 'parent-1', name: 'Test' }],
})
})
it('after creating subfolder, clicking its name navigates correctly', async () => {
const initialChildren = [makeFolder({ id: 'existing-1', name: 'Existing', parent_id: 'parent-1' })]
mockListFolders.mockResolvedValue({ items: initialChildren })
const newSubfolder = makeFolder({ id: 'new-sub', name: 'NewSub', parent_id: 'parent-1' })
mockCreateFolder.mockResolvedValue(newSubfolder)
const { w, router } = await mountView('/folders/parent-1')
// Simulate creating a new subfolder (as if user typed in the new-folder input)
const { useFoldersStore } = await import('../../stores/folders.js')
const foldersStore = useFoldersStore()
await foldersStore.createFolder('NewSub', 'parent-1')
await flushPromises()
// Now "NewSub" should appear in the list
expect(w.text()).toContain('NewSub')
// Clicking the name of "NewSub" should navigate — this is the regression test
const nameSpan = w.findAll('span').find(s => s.text() === 'NewSub')
expect(nameSpan).toBeTruthy()
await nameSpan.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/new-sub')
})
it('after creating subfolder, clicking existing folder name still navigates', async () => {
const initial = [makeFolder({ id: 'existing-1', name: 'Existing', parent_id: 'parent-1' })]
mockListFolders.mockResolvedValue({ items: initial })
mockCreateFolder.mockResolvedValue(
makeFolder({ id: 'new-sub', name: 'NewSub', parent_id: 'parent-1' })
)
const { w, router } = await mountView('/folders/parent-1')
const { useFoldersStore } = await import('../../stores/folders.js')
await useFoldersStore().createFolder('NewSub', 'parent-1')
await flushPromises()
const nameSpan = w.findAll('span').find(s => s.text() === 'Existing')
expect(nameSpan).toBeTruthy()
await nameSpan.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/existing-1')
})
})
describe('FileManagerView — drag-and-drop document onto folder', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMoveDocument.mockResolvedValue({})
mockListDocuments.mockResolvedValue({
items: [makeDoc({ id: 'd1', original_name: 'doc.pdf' })],
total: 1,
})
mockListFolders.mockResolvedValue({
items: [makeFolder({ id: 'f-target', name: 'Target' })],
})
mockGetFolder.mockResolvedValue({
id: 'parent-1', name: 'Parent',
breadcrumb: [{ id: 'parent-1', name: 'Parent' }],
})
})
it('dragstart on document row sets dragging state', async () => {
const { w } = await mountView('/folders/parent-1')
const docRow = w.findAll('[draggable="true"]')[0]
expect(docRow).toBeTruthy()
await docRow.trigger('dragstart', { dataTransfer: { effectAllowed: '', setData: vi.fn(), types: [] } })
// The row gets opacity-50 when dragging
await w.vm.$nextTick()
})
it('drop on folder row calls moveToFolder', async () => {
const { w } = await mountView('/folders/parent-1')
await flushPromises()
// Start dragging doc
const docRow = w.findAll('[draggable="true"]')[0]
const dataTransfer = { effectAllowed: '', setData: vi.fn(), types: [] }
await docRow.trigger('dragstart', { dataTransfer })
await w.vm.$nextTick()
// Find folder row and drop on it
const folderRows = w.findAll('.grid.grid-cols-\\[2rem_1fr_6rem_8rem_6rem\\]').filter(
el => el.text().includes('Target')
)
if (folderRows.length > 0) {
await folderRows[0].trigger('drop', { dataTransfer: { types: [] } })
await flushPromises()
expect(mockMoveDocument).toHaveBeenCalled()
}
})
})
describe('FileManagerView — empty states', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Empty', breadcrumb: [] })
})
it('shows "No folders yet" when root has no folders', async () => {
const { w } = await mountView('/')
expect(w.text()).toContain('No folders yet')
})
it('shows "This folder is empty" inside an empty subfolder', async () => {
const { w } = await mountView('/folders/f1')
expect(w.text()).toContain('This folder is empty')
})
})
describe('FileManagerView — new folder creation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] })
mockCreateFolder.mockResolvedValue(makeFolder({ id: 'new-1', name: 'MyNewFolder' }))
})
it('clicking "New folder" button shows inline input', async () => {
const { w } = await mountView('/')
const newBtn = w.find('button.flex.items-center.gap-1\\.5')
await newBtn.trigger('click')
await w.vm.$nextTick()
expect(w.find('input[placeholder="Folder name"]').exists()).toBe(true)
})
it('pressing Enter in folder name input creates folder at current level', async () => {
const { w } = await mountView('/folders/f1')
// Open new folder input
await w.find('button.flex.items-center.gap-1\\.5').trigger('click')
await w.vm.$nextTick()
// Type folder name and submit
const input = w.find('input[placeholder="Folder name"]')
await input.setValue('MyNewFolder')
await input.trigger('keydown.enter')
await flushPromises()
expect(mockCreateFolder).toHaveBeenCalledWith('MyNewFolder', 'f1')
})
it('pressing Escape cancels folder creation', async () => {
const { w } = await mountView('/')
await w.find('button.flex.items-center.gap-1\\.5').trigger('click')
await w.vm.$nextTick()
await w.find('input[placeholder="Folder name"]').trigger('keydown.escape')
await w.vm.$nextTick()
expect(w.find('input[placeholder="Folder name"]').exists()).toBe(false)
})
})
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
},
})