9f8ec141f3
Step 8 of docs/plan/derived-blobs.md. "What follows a file on copy" was written twice — the copy_file CTE and storage.copy_folder_tree — and had already drifted: the tree path bumped storage.blobs only, missing manifests, which was silent data loss on any multi-chunk file. Fixing it meant writing the same logic a second time. Step 9 adds a file-keyed satellite table, which would mean a third and fourth. Two SQL functions: storage.add_blob_references(TEXT[]) — the manifest-first reference contract for SQL callers, returning hashes that matched no registry row. Set-based so the tree path keeps its single-statement cost; a per-row helper would have made a 10k-file copy 10k calls. storage.copy_file_satellites(UUID[], UUID[]) — dead properties plus the blob reference. The body is the copy-semantics declaration: what is absent (comments, favorites, content-keyed derived rows) is listed with its reason, so the taxonomy is executable rather than documented elsewhere and drifting. Both copy paths now call it. The single-file path becomes a real transaction, which also fixes the reference being best-effort: a failed add_reference used to log a warning and leave a copy holding no reference at all — the exact shape that gets its content reaped. It cannot be a CTE arm, because data-modifying CTEs share one snapshot and the function must read the row the INSERT just wrote. Verified against a scratch PG with all migrations applied: multi-chunk manifest 1→2, single-chunk alias bumped at manifest level only (the NOT EXISTS guard), chunks behind a manifest untouched, dead properties duplicated, length mismatch rejected, repeats counted. tests/api/derived_blob_copy.hurl covers it end-to-end and answers the question the copy raises: content_derived_blobs is NOT copied. A copy carries the same blob_hash, so it resolves the same derived row — the test asserts byte-identical thumbnails from both copy paths, then deletes the original, runs GC, and requires both copies to still serve. That last step only passes if the references are real.
API functional tests
Tests are written using Hurl — a plain-text, CLI-first HTTP testing tool.
Prerequisites
- Hurl ≥ 4.0
Install:
cargo install hurlor via your package manager
Configuration
Edit test.env to match your local instance. This file is the single source of
truth: run.sh sources it for shell variables and passes it to Hurl as
--variables-file.
base_url=http://localhost:8087
username=admin
email=admin@example.com
password=TestPassword1!
Running the tests
# First-time setup (run once on a fresh instance)
hurl --variables-file tests/api/test.env --test tests/api/setup.hurl
# Contacts CRUD scenario
hurl --variables-file tests/api/test.env --test tests/api/contacts.hurl
# All scenarios at once
hurl --variables-file tests/api/test.env --test tests/api/setup.hurl tests/api/contacts.hurl
# With full request/response output
hurl --variables-file tests/api/test.env --test --verbose tests/api/contacts.hurl
# Generate an HTML report
hurl --variables-file tests/api/test.env --test --report-html /tmp/hurl-report tests/api/contacts.hurl
Test files
| File | Description |
|---|---|
setup.hurl |
One-time admin account creation; also asserts the endpoint is locked afterwards |
files-folders.hurl |
Files & folders CRUD scenario (22 steps, see below) |
favorites.hurl |
Favorites add/list/remove scenario (11 steps); depends on files-folders.hurl state |
trash.hurl |
Trash move/restore/purge scenario (16 steps); depends on files-folders.hurl state |
recent.hurl |
Recent items record/list/clear scenario (6 steps); depends on files-folders.hurl state |
contacts.hurl |
Full contacts CRUD scenario (14 steps, see below) |
test.env |
Variables: base_url, username, email, password — used by both Hurl and run.sh |
Scenario: files-folders.hurl
| Step | Description |
|---|---|
| 1 | Login – capture JWT token |
| 2 | List root folders – assert exactly 1 (home folder), capture home_folder_id, assert parent_id is null |
| 3 | Browse home folder sub-folders – assert empty |
| 4 | Browse home folder files – assert empty |
| 5 | Create folder named "/" – assert HTTP 400, error_type == "Invalid Input" |
| 6 | Create test1 inside home folder – capture test1_id, assert parent_id == home_folder_id |
| 7 | Create test2 inside test1 – capture test2_id, assert parent_id == test1_id |
| 8 | Browse home folder – assert exactly 1 sub-folder (test1) |
| 9 | Browse test1 – assert exactly 1 sub-folder (test2), assert parent_id |
| 10 | Upload fixtures/hello.txt into test2 – capture file_id, assert name/size/mime_type/folder_id |
| 11 | List files in test2 – assert count=1, mime_type == text/plain, icon_class == fas fa-file-alt |
| 12 | Move test2 from test1 into home folder – assert parent_id == home_folder_id |
| 13 | Browse test1 – assert empty (no more children) |
| 14 | Browse home folder – assert 2 sub-folders (test1 and test2) |
| 15 | Rename test2 → test2-renamed – assert new name, parent_id unchanged |
| 16 | Rename hello.txt → hello-renamed.txt – assert new name, folder_id unchanged |
| 17 | Rename hello-renamed.txt to "." – assert HTTP 400, error_type == "Invalid Input" |
| 18 | Upload fixtures/oxicloud-logo.jpg into home folder – capture logo_id, assert mime_type == image/jpeg |
| 19 | List files in home folder – assert count=1, icon_class == fas fa-file-image |
| 20 | GET /api/files/{logo_id}/thumbnail/icon → HTTP 200 |
| 21 | GET /api/files/{logo_id}/thumbnail/preview → HTTP 200 |
| 22 | GET /api/files/{logo_id}/thumbnail/large → HTTP 200 |
Scenario: favorites.hurl
Depends on files-folders.hurl having run first (test1, test2-renamed/hello-renamed.txt must exist).
| Step | Description |
|---|---|
| 1 | Login – capture JWT token |
| 2 | Assert no favorites yet |
| 3 | Discover item IDs: home folder → contents (test1=$[0], test2-renamed=$[1]) → files in test2-renamed |
| 4 | POST /api/favorites/file/{file_id} – add hello-renamed.txt → HTTP 201 |
| 5 | List favorites – assert count=1, item_type=file, item_name=hello-renamed.txt |
| 6 | POST /api/favorites/folder/{test1_id} – add test1 → HTTP 201 |
| 7 | List favorites – assert count=2, both IDs present (order-independent) |
| 8 | DELETE /api/favorites/file/{file_id} – remove hello-renamed.txt → HTTP 200 |
| 9 | List favorites – assert count=1, item_type=folder, item_name=test1 |
| 10 | Cleanup: DELETE /api/favorites/folder/{test1_id} → HTTP 200 |
| 11 | List favorites – assert count=0 |
Scenario: trash.hurl
Depends on files-folders.hurl having run first (home folder must exist).
| Step | Description |
|---|---|
| 1 | Login – capture JWT token |
| 2 | Assert trash is empty |
| 3 | Capture home folder ID |
| 4 | Create to-delete folder in home folder – capture to_delete_id |
| 5 | Upload hello.txt into to-delete |
| 6 | DELETE /api/folders/{to_delete_id} – moves folder to trash → HTTP 204 |
| 7 | List trash – assert count=1, item_type=folder, name=to-delete, original_id matches; capture trash_id |
| 8 | List home folder contents – assert to-delete is not present |
| 9 | POST /api/trash/{trash_id}/restore → HTTP 200, success == true |
| 10 | List home folder contents – assert to-delete is present |
| 11 | List files in to-delete – assert count=1, name=hello.txt |
| 12 | List trash – assert empty (restore removed the entry) |
| 13 | DELETE /api/folders/{to_delete_id} – move restored folder to trash again → HTTP 204 |
| 14 | DELETE /api/trash/empty – purge trash → HTTP 200, success == true |
| 15 | List trash – assert empty after purge |
| 16 | List home folder contents – assert to-delete is not present (permanently gone) |
Scenario: recent.hurl
Depends on files-folders.hurl having run first (test2-renamed/hello-renamed.txt must exist).
Recent items are not auto-recorded on upload — step 3 explicitly registers the access.
| Step | Description |
|---|---|
| 1 | Login – capture JWT token |
| 2 | Discover file_id of hello-renamed.txt via home folder → test2-renamed contents |
| 3 | POST /api/recent/file/{file_id} – record access → HTTP 200 |
| 4 | GET /api/recent – assert count=1, item_type=file, item_name=hello-renamed.txt |
| 5 | DELETE /api/recent/clear – clear all recent items → HTTP 200 |
| 6 | GET /api/recent – assert count=0 |
Scenario: contacts.hurl
| Step | Description |
|---|---|
| 1 | Login – capture JWT token |
| 2 | List address books – assert system book is present and read-only |
| 3 | Create personal address book – capture book_id |
| 4 | List contacts in new book – assert empty |
| 5 | Create contact John Doe – capture contact_id |
| 6 | List contacts – assert exactly 1 result with John Doe's id |
| 7 | Get John Doe – assert all fields, capture ETag |
| 8 | Update John Doe (nickname, org, notes) with If-Match – assert new values, capture refreshed ETag |
| 9 | Delete John Doe with If-Match |
| 10 | List contacts – assert empty again |
| 11 | Delete personal address book |
| 12 | List address books – assert book_id no longer present |
| 13 | List system address book – assert non-empty collection of OxiCloud users |
Legacy bash tests
test.sh and common.sh are the original curl/bash scripts kept for reference.
Run them with bash tests/api/test.sh from the repo root (requires jq).