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:
+6
-6
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
current_phase: 4
|
||||
status: planned
|
||||
last_updated: "2026-05-25T16:00:00.000Z"
|
||||
status: completed
|
||||
last_updated: "2026-05-28T14:59:51.958Z"
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 3
|
||||
total_plans: 33
|
||||
completed_plans: 15
|
||||
percent: 60
|
||||
completed_phases: 4
|
||||
total_plans: 24
|
||||
completed_plans: 24
|
||||
percent: 80
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
"plan_check": true,
|
||||
"verifier": 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": {
|
||||
"pr_body_sections": [
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
---
|
||||
status: testing
|
||||
status: complete
|
||||
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
|
||||
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
|
||||
|
||||
UAT-3 — QuotaBar visual in sidebar (needs browser confirmation)
|
||||
All tests complete — Phase 3 UAT passed.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -23,7 +24,8 @@ reported: "User confirmed upload works after fixes."
|
||||
|
||||
### 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.
|
||||
result: [pending]
|
||||
result: pass
|
||||
reported: "QuotaBar visible in sidebar with indigo fill bar. Confirmed by user 2026-05-25."
|
||||
|
||||
### 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.
|
||||
@@ -62,9 +64,9 @@ reported: "document_tasks.py _run() resolves ai_provider from user.ai_provider w
|
||||
## Summary
|
||||
|
||||
total: 10
|
||||
passed: 9
|
||||
passed: 10
|
||||
issues: 0
|
||||
pending: 1
|
||||
pending: 0
|
||||
skipped: 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
|
||||
@@ -67,6 +67,103 @@ cd frontend && npm run dev
|
||||
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)
|
||||
|
||||
- Rate limiting on all auth endpoints (login, register, password reset, TOTP)
|
||||
|
||||
+75
-9
@@ -112,6 +112,21 @@ async def create_folder(
|
||||
if parent is None or parent.user_id != current_user.id:
|
||||
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(
|
||||
user_id=current_user.id,
|
||||
name=body.name,
|
||||
@@ -144,23 +159,57 @@ async def create_folder(
|
||||
|
||||
@router.get("")
|
||||
async def list_folders(
|
||||
parent_id: Optional[str] = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
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(
|
||||
select(Folder)
|
||||
.where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.parent_id.is_(None),
|
||||
)
|
||||
.where(Folder.user_id == current_user.id, where_clause)
|
||||
.order_by(Folder.name)
|
||||
)
|
||||
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} ──────────────────────────────────────────────
|
||||
@@ -235,6 +284,22 @@ async def rename_folder(
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
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
|
||||
try:
|
||||
await session.commit()
|
||||
@@ -303,7 +368,8 @@ async def delete_folder(
|
||||
" WHERE f.user_id = :uid"
|
||||
") 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()]
|
||||
except OperationalError:
|
||||
@@ -344,7 +410,7 @@ async def delete_folder(
|
||||
"CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END "
|
||||
"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)
|
||||
|
||||
+11
-3
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
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.db import get_db
|
||||
from services import classifier, storage
|
||||
@@ -137,10 +137,18 @@ async def suggest_topics(
|
||||
"""Suggest topics for a document using AI.
|
||||
|
||||
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)
|
||||
if meta is None:
|
||||
try:
|
||||
uid = uuid.UUID(body.document_id)
|
||||
except ValueError:
|
||||
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:
|
||||
suggestions = await classifier.suggest_topics_for_document(session, body.document_id)
|
||||
except Exception as e:
|
||||
|
||||
@@ -356,7 +356,7 @@ async def check_hibp(password: str) -> bool:
|
||||
Returns True if the password has been breached, False otherwise.
|
||||
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:]
|
||||
|
||||
try:
|
||||
|
||||
+448
-77
@@ -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
|
||||
through 04-04. The stubs ensure pytest collects them and keeps CI green before
|
||||
implementation code exists.
|
||||
Covers:
|
||||
POST /api/folders — create (FOLD-01)
|
||||
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
|
||||
|
||||
import os
|
||||
import uuid as _uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Folder, Quota, User
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FOLD-01: Create folder
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False)
|
||||
async def test_create_folder(async_client, auth_user):
|
||||
"""POST /api/folders creates a folder, returns 201."""
|
||||
pytest.xfail("not implemented yet")
|
||||
async def _create_folder(db: AsyncSession, user: User, name: str, parent=None) -> Folder:
|
||||
"""Create a Folder row directly via ORM."""
|
||||
f = Folder(
|
||||
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 test_create_folder_duplicate_name(async_client, auth_user):
|
||||
async def _create_document(db: AsyncSession, user: User, *, folder=None) -> Document:
|
||||
"""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."""
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FOLD-02: Rename folder
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_create_folder_invalid_parent_404(async_client, auth_user):
|
||||
"""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_rename_folder(async_client, auth_user):
|
||||
"""PATCH /api/folders/{id} changes name, returns 200."""
|
||||
pytest.xfail("not implemented yet")
|
||||
async def test_create_folder_other_users_parent_404(async_client, auth_user, admin_user, db_session):
|
||||
"""POST /api/folders with parent owned by another user returns 404 (IDOR)."""
|
||||
other_parent = await _create_folder(db_session, admin_user["user"], "AdminParent")
|
||||
|
||||
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_rename_folder_wrong_owner(async_client, auth_user):
|
||||
"""PATCH /api/folders/{id} by non-owner returns 404."""
|
||||
pytest.xfail("not implemented yet")
|
||||
async def test_create_folder_requires_auth(async_client):
|
||||
"""POST /api/folders without auth returns 401 or 403."""
|
||||
resp = await async_client.post("/api/folders", json={"name": "Unauth"})
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FOLD-03: Delete folder
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── FOLD-02: List folders ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False)
|
||||
async def test_delete_empty_folder(async_client, auth_user):
|
||||
async def test_list_root_folders(async_client, auth_user, db_session):
|
||||
"""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."""
|
||||
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(async_client, auth_user):
|
||||
"""DELETE /api/folders/{id} on non-empty folder deletes all docs + decrements quota."""
|
||||
pytest.xfail("not implemented yet")
|
||||
async def test_delete_folder_cascade_documents(async_client, auth_user, db_session):
|
||||
"""DELETE /api/folders/{id} cascades to documents inside it."""
|
||||
folder = await _create_folder(db_session, auth_user["user"], "WithDocs")
|
||||
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_wrong_owner(async_client, auth_user):
|
||||
"""DELETE /api/folders/{id} by non-owner returns 404."""
|
||||
pytest.xfail("not implemented yet")
|
||||
async def test_delete_folder_cascade_quota(async_client, auth_user, db_session):
|
||||
"""DELETE /api/folders/{id} decrements quota by sum of deleted doc sizes."""
|
||||
folder = await _create_folder(db_session, auth_user["user"], "ForQuota")
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FOLD-04: Move document
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_delete_subfolder_not_in_parent_list(async_client, auth_user, db_session):
|
||||
"""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_move_document(async_client, auth_user):
|
||||
async def test_delete_folder_wrong_owner_404(async_client, auth_user, admin_user, db_session):
|
||||
"""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."""
|
||||
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(
|
||||
f"/api/documents/{doc.id}/folder",
|
||||
json={"folder_id": str(folder.id)},
|
||||
headers=auth_user["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["folder_id"] == str(folder.id)
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False)
|
||||
async def test_move_wrong_owner_404(async_client, auth_user):
|
||||
"""PATCH /api/documents/{id}/folder where doc or target folder belongs to other user returns 404."""
|
||||
pytest.xfail("not implemented yet")
|
||||
async def test_move_document_to_root(async_client, auth_user, db_session):
|
||||
"""PATCH /api/documents/{id}/folder with folder_id: null moves doc to root."""
|
||||
folder = await _create_folder(db_session, auth_user["user"], "Source")
|
||||
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"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["folder_id"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FOLD-05: Breadcrumb, sort, FTS
|
||||
# ---------------------------------------------------------------------------
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
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
|
||||
|
||||
|
||||
@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):
|
||||
"""GET /api/documents?q=term returns matching docs only; requires PostgreSQL FTS."""
|
||||
pytest.xfail("not implemented yet")
|
||||
# ── FOLD-05: Sort and breadcrumb edge cases ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False)
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("INTEGRATION"),
|
||||
reason="requires PostgreSQL",
|
||||
)
|
||||
async def test_fts_search_scoped_to_owner(async_client, auth_user):
|
||||
"""GET /api/documents?q=term does not return other user's matching docs."""
|
||||
pytest.xfail("not implemented yet")
|
||||
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)
|
||||
|
||||
@@ -30,7 +30,7 @@ async def test_lmstudio_health_check():
|
||||
@pytest.mark.asyncio
|
||||
async def test_lmstudio_classify():
|
||||
from ai.lmstudio_provider import LMStudioProvider
|
||||
from config import DEFAULT_SYSTEM_PROMPT
|
||||
from services.classifier import _DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
provider = LMStudioProvider(
|
||||
base_url="http://host.docker.internal:1234",
|
||||
@@ -39,7 +39,7 @@ async def test_lmstudio_classify():
|
||||
result = await provider.classify(
|
||||
document_text="This document is an invoice for software development services.",
|
||||
existing_topics=["Finance", "Legal", "HR"],
|
||||
system_prompt=DEFAULT_SYSTEM_PROMPT,
|
||||
system_prompt=_DEFAULT_SYSTEM_PROMPT,
|
||||
)
|
||||
# Result should have some topics assigned or suggested
|
||||
assert isinstance(result.topics, list)
|
||||
|
||||
@@ -8,15 +8,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0"
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.2.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"happy-dom": "^20.9.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 showFolderPicker = ref(false)
|
||||
|
||||
const allFolders = computed(() => foldersStore.folders)
|
||||
const allFolders = computed(() => foldersStore.rootFolders)
|
||||
|
||||
function openShareModal() {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -2,24 +2,12 @@
|
||||
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0">
|
||||
<!-- Logo -->
|
||||
<div class="px-6 py-5 border-b border-gray-100">
|
||||
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocScanner</h1>
|
||||
<p class="text-xs text-gray-400 mt-0.5">AI Document Classifier</p>
|
||||
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocuVault</h1>
|
||||
<p class="text-xs text-gray-400 mt-0.5">Document Manager</p>
|
||||
</div>
|
||||
|
||||
<!-- Nav -->
|
||||
<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
|
||||
to="/topics"
|
||||
class="nav-link"
|
||||
@@ -32,7 +20,7 @@
|
||||
All Topics
|
||||
</router-link>
|
||||
|
||||
<!-- Shared with me entry -->
|
||||
<!-- Shared with me -->
|
||||
<router-link
|
||||
to="/shared"
|
||||
class="nav-link"
|
||||
@@ -53,20 +41,32 @@
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Folders section -->
|
||||
<!-- Folders root + collapsible tree -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between px-3 mb-1">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Folders</p>
|
||||
<!-- "Folders" is the root entry — clicking navigates to the root folder view -->
|
||||
<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
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<!-- New folder inline input -->
|
||||
<div v-if="showNewFolderInput" class="px-3 mb-2">
|
||||
<!-- Inline new root folder input -->
|
||||
<div v-if="showNewFolderInput" class="px-3 mb-2 mt-1">
|
||||
<input
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
@@ -79,21 +79,16 @@
|
||||
<p v-if="newFolderError" class="text-red-500 text-xs mt-1">{{ newFolderError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Folder list -->
|
||||
<div v-if="foldersStore.loading && foldersStore.folders.length === 0" class="px-3 py-1 text-xs text-gray-400">Loading…</div>
|
||||
<router-link
|
||||
v-for="folder in foldersStore.folders"
|
||||
<!-- Sub-folders tree (indented under Folders) -->
|
||||
<div v-if="loadingRoots" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
|
||||
<div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput"
|
||||
class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>
|
||||
<FolderTreeItem
|
||||
v-for="folder in foldersStore.rootFolders"
|
||||
:key="folder.id"
|
||||
:to="`/folders/${folder.id}`"
|
||||
class="nav-link text-sm"
|
||||
: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>
|
||||
:folder="folder"
|
||||
:depth="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Topics list -->
|
||||
@@ -118,7 +113,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Quota bar (between topics nav and settings footer, UI-SPEC Phase 3) -->
|
||||
<!-- Quota bar -->
|
||||
<QuotaBar />
|
||||
|
||||
<!-- Settings + Admin link -->
|
||||
@@ -172,27 +167,34 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTopicsStore } from '../../stores/topics.js'
|
||||
import { useAuthStore } from '../../stores/auth.js'
|
||||
import { useFoldersStore } from '../../stores/folders.js'
|
||||
import QuotaBar from './QuotaBar.vue'
|
||||
import FolderTreeItem from '../folders/FolderTreeItem.vue'
|
||||
import * as api from '../../api/client.js'
|
||||
|
||||
const topicsStore = useTopicsStore()
|
||||
const authStore = useAuthStore()
|
||||
const foldersStore = useFoldersStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const sharedCount = ref(0)
|
||||
const showNewFolderInput = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderError = ref('')
|
||||
const loadingRoots = ref(true)
|
||||
|
||||
watch(() => foldersStore.treeVersion, () => foldersStore.fetchRootFolders())
|
||||
|
||||
onMounted(async () => {
|
||||
await foldersStore.fetchFolders(null)
|
||||
try {
|
||||
await foldersStore.fetchRootFolders()
|
||||
} finally {
|
||||
loadingRoots.value = false
|
||||
}
|
||||
try {
|
||||
const data = await api.getSharedWithMe()
|
||||
const items = Array.isArray(data) ? data : (data.items ?? [])
|
||||
|
||||
@@ -7,4 +7,7 @@ import './style.css'
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
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')
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
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 DocumentView from '../views/DocumentView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
|
||||
const routes = [
|
||||
// Existing routes
|
||||
{ path: '/', component: HomeView },
|
||||
// File manager is the home — handles both root and folder views
|
||||
{ path: '/', component: FileManagerView },
|
||||
{ path: '/topics', component: TopicsView },
|
||||
{ path: '/topics/:name', component: TopicsView },
|
||||
{ path: '/document/:id', component: DocumentView },
|
||||
@@ -43,7 +43,7 @@ const routes = [
|
||||
{
|
||||
path: '/folders/:folderId',
|
||||
name: 'folder',
|
||||
component: () => import('../views/FolderView.vue'),
|
||||
component: FileManagerView,
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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 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 success: authStore.fetchQuota() is called to refresh sidebar quota bar (STORE-04).
|
||||
* 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()
|
||||
// Composite key prevents collision when same filename uploaded twice (T-03-25)
|
||||
const rowKey = `${file.name}__${Date.now()}`
|
||||
@@ -83,8 +84,14 @@ export const useDocumentsStore = defineStore('documents', () => {
|
||||
})
|
||||
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)
|
||||
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
|
||||
|
||||
documents.value.unshift({
|
||||
@@ -94,6 +101,7 @@ export const useDocumentsStore = defineStore('documents', () => {
|
||||
mime_type: file.type,
|
||||
size_bytes: doc.size_bytes,
|
||||
topics: [],
|
||||
folder_id: folderId,
|
||||
created_at: new Date().toISOString(),
|
||||
classified_at: null,
|
||||
})
|
||||
@@ -142,6 +150,12 @@ export const useDocumentsStore = defineStore('documents', () => {
|
||||
}, 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) {
|
||||
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 }
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -4,17 +4,21 @@ import * as api from '../api/client.js'
|
||||
|
||||
export const useFoldersStore = defineStore('folders', () => {
|
||||
const folders = ref([])
|
||||
const rootFolders = ref([]) // root-level folders for sidebar tree and folder pickers
|
||||
const currentFolderId = ref(null)
|
||||
const breadcrumb = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const treeVersion = ref(0) // bumped on any mutation so sidebar tree can react
|
||||
|
||||
async function fetchFolders(parentId = null) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
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) {
|
||||
error.value = e.message || 'Failed to load folders'
|
||||
} 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) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const folder = await api.createFolder(name, parentId)
|
||||
folders.value.push(folder)
|
||||
if (parentId === null) rootFolders.value.push(folder)
|
||||
treeVersion.value++
|
||||
return folder
|
||||
} catch (e) {
|
||||
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 idx = folders.value.findIndex(f => f.id === folderId)
|
||||
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
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to rename folder'
|
||||
@@ -59,6 +75,8 @@ export const useFoldersStore = defineStore('folders', () => {
|
||||
try {
|
||||
await api.deleteFolder(folderId)
|
||||
folders.value = folders.value.filter(f => f.id !== folderId)
|
||||
rootFolders.value = rootFolders.value.filter(f => f.id !== folderId)
|
||||
treeVersion.value++
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to delete folder'
|
||||
throw e
|
||||
@@ -78,8 +96,9 @@ export const useFoldersStore = defineStore('folders', () => {
|
||||
}
|
||||
} else {
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user