test(api): add API test on contacts

next will be to add CI on it
This commit is contained in:
Edouard Vanbelle
2026-05-11 00:50:39 +02:00
parent 03aac93db3
commit 8ab537797a
14 changed files with 530 additions and 55 deletions
+71
View File
@@ -0,0 +1,71 @@
# API functional tests
Tests are written using [Hurl](https://hurl.dev) — a plain-text, CLI-first HTTP testing tool.
## Prerequisites
- [Hurl](https://hurl.dev/docs/installation.html) ≥ 4.0
Install: `cargo install hurl` or via your package manager
## Configuration
Edit `.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
```bash
# First-time setup (run once on a fresh instance)
hurl --variables-file tests/api/hurl.vars --test tests/api/setup.hurl
# Contacts CRUD scenario
hurl --variables-file tests/api/hurl.vars --test tests/api/contacts.hurl
# All scenarios at once
hurl --variables-file tests/api/hurl.vars --test tests/api/setup.hurl tests/api/contacts.hurl
# With full request/response output
hurl --variables-file tests/api/hurl.vars --test --verbose tests/api/contacts.hurl
# Generate an HTML report
hurl --variables-file tests/api/hurl.vars --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 |
| `contacts.hurl` | Full contacts CRUD scenario (13 steps, see below) |
| `.env` | Variables: `base_url`, `username`, `email`, `password` — used by both Hurl and `run.sh` |
## 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`).
+258
View File
@@ -0,0 +1,258 @@
# =============================================================
# OxiCloud – Contacts API end-to-end scenario
# =============================================================
# Run:
# hurl --variables-file tests/api/hurl.vars --test tests/api/contacts.hurl
#
# Variables required (see hurl.vars):
# base_url – e.g. http://localhost:8087
# username – OxiCloud username
# password – OxiCloud password
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 – Login and capture the JWT token
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "{{username}}",
"password": "{{password}}"
}
HTTP 200
[Captures]
token: jsonpath "$.access_token"
[Asserts]
jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer"
# ─────────────────────────────────────────────────────────────
# Step 2 – List address books (at least the system book)
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
# System address book must always be present
jsonpath "$[?(@.id == 'system')].is_system" == true
jsonpath "$[?(@.id == 'system')].is_readonly" == true
# ─────────────────────────────────────────────────────────────
# Step 3 – Create a personal address book
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/address-books
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "Personal",
"description": "Personal address book created by Hurl tests",
"is_public": false
}
HTTP 201
[Captures]
book_id: jsonpath "$.id"
[Asserts]
jsonpath "$.id" isString
jsonpath "$.name" == "Personal"
jsonpath "$.description" == "Personal address book created by Hurl tests"
jsonpath "$.is_public" == false
jsonpath "$.is_system" == false
jsonpath "$.is_readonly" == false
# ─────────────────────────────────────────────────────────────
# Step 4 – List contacts in the new book – must be empty
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/{{book_id}}/contacts
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
jsonpath "$" count == 0
# ─────────────────────────────────────────────────────────────
# Step 5 – Create contact John Doe
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/address-books/{{book_id}}/contacts
Authorization: Bearer {{token}}
Content-Type: application/json
{
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"email": [
{
"email": "john.doe@example.com",
"type": "work",
"is_primary": true
}
],
"phone": [
{
"number": "+1-555-0100",
"type": "mobile",
"is_primary": true
}
]
}
HTTP 201
[Captures]
contact_id: jsonpath "$.id"
[Asserts]
jsonpath "$.id" isString
jsonpath "$.address_book_id" == {{book_id}}
jsonpath "$.first_name" == "John"
jsonpath "$.last_name" == "Doe"
jsonpath "$.full_name" == "John Doe"
jsonpath "$.email" count == 1
jsonpath "$.email[0].email" == "john.doe@example.com"
jsonpath "$.email[0].type" == "work"
jsonpath "$.email[0].is_primary" == true
jsonpath "$.phone" count == 1
jsonpath "$.phone[0].number" == "+1-555-0100"
jsonpath "$.phone[0].type" == "mobile"
jsonpath "$.phone[0].is_primary" == true
# ─────────────────────────────────────────────────────────────
# Step 6 – List contacts – exactly 1 result, must be John Doe
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/{{book_id}}/contacts
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 1
jsonpath "$[*].id" contains {{contact_id}}
jsonpath "$[0].first_name" == "John"
jsonpath "$[0].last_name" == "Doe"
jsonpath "$[0].full_name" == "John Doe"
# ─────────────────────────────────────────────────────────────
# Step 7 – Get John Doe by id and verify all fields
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
Authorization: Bearer {{token}}
HTTP 200
[Captures]
etag: header "ETag"
[Asserts]
header "ETag" exists
jsonpath "$.id" == {{contact_id}}
jsonpath "$.address_book_id" == {{book_id}}
jsonpath "$.first_name" == "John"
jsonpath "$.last_name" == "Doe"
jsonpath "$.full_name" == "John Doe"
jsonpath "$.email[0].email" == "john.doe@example.com"
jsonpath "$.email[0].type" == "work"
jsonpath "$.email[0].is_primary" == true
jsonpath "$.phone[0].number" == "+1-555-0100"
jsonpath "$.phone[0].type" == "mobile"
# ─────────────────────────────────────────────────────────────
# Step 8 – Update John Doe: add nickname, notes, and organisation
# Uses If-Match for optimistic concurrency
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
Authorization: Bearer {{token}}
Content-Type: application/json
If-Match: {{etag}}
{
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"nickname": "JD",
"organization": "ACME Corp",
"notes": "Updated via Hurl test",
"email": [
{
"email": "john.doe@example.com",
"type": "work",
"is_primary": true
}
],
"phone": [
{
"number": "+1-555-0100",
"type": "mobile",
"is_primary": true
}
]
}
HTTP 200
[Captures]
etag: header "ETag"
[Asserts]
header "ETag" exists
jsonpath "$.id" == {{contact_id}}
jsonpath "$.first_name" == "John"
jsonpath "$.last_name" == "Doe"
jsonpath "$.nickname" == "JD"
jsonpath "$.organization" == "ACME Corp"
jsonpath "$.notes" == "Updated via Hurl test"
# ─────────────────────────────────────────────────────────────
# Step 9 – Delete John Doe (uses refreshed ETag from step 8)
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
Authorization: Bearer {{token}}
If-Match: {{etag}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 10 – Address book must be empty again
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/{{book_id}}/contacts
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 0
# ─────────────────────────────────────────────────────────────
# Step 11 – Delete the personal address book
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/address-books/{{book_id}}
Authorization: Bearer {{token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 12 – Verify the address book is gone from the list
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" not contains {{book_id}}
# ─────────────────────────────────────────────────────────────
# Step 13 – List the system address book (OxiCloud users)
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/system/contacts
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Full Hurl API test runner.
# Starts postgres + OxiCloud server, runs Hurl tests, tears everything down.
#
# Usage (from repo root):
# bash tests/api/run.sh
#
# Prerequisites: docker, cargo, hurl ≥ 4.0
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
COMMON="$REPO_ROOT/tests/common"
API_DIR="$REPO_ROOT/tests/api"
# .env is the single source of truth for connection details and credentials.
# shellcheck source=.env
source "$API_DIR/.env"
# Derive server port from base_url (e.g. http://localhost:8087 → 8087)
SERVER_PORT="${base_url##*:}"
# ── Helpers ───────────────────────────────────────────────────────────────────
log() { echo "[api-test] $*"; }
die() { echo "[api-test] ERROR: $*" >&2; exit 1; }
wait_for_http() {
local url="$1" timeout="${2:-60}"
local deadline=$(( $(date +%s) + timeout ))
until curl -sf "$url" >/dev/null 2>&1; do
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
sleep 1
done
}
# ── Teardown (always runs on exit) ────────────────────────────────────────────
SERVER_PID=""
cleanup() {
if [[ -n "$SERVER_PID" ]]; then
log "Stopping OxiCloud server (pid $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
bash "$COMMON/stop-db.sh"
}
trap cleanup EXIT
# ── 1. Start postgres ─────────────────────────────────────────────────────────
bash "$COMMON/spawn-db.sh"
# ── 2. Load shared server env + port from .env ───────────────────────────────
set -a
# shellcheck source=../common/server.env
source "$COMMON/server.env"
OXICLOUD_SERVER_PORT=$SERVER_PORT
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/api/storage"
set +a
mkdir -p "$OXICLOUD_STORAGE_PATH"
# ── 3. Start OxiCloud server ──────────────────────────────────────────────────
log "Building and starting OxiCloud server on port $SERVER_PORT..."
cd "$REPO_ROOT"
cargo run &
SERVER_PID=$!
log "Waiting for server at $base_url..."
wait_for_http "$base_url/ready" 120
log "Server is ready."
# ── 4. Run Hurl tests ─────────────────────────────────────────────────────────
log "Running Hurl tests..."
hurl --variables-file "$API_DIR/.env" --test --jobs 1 \
"$API_DIR/setup.hurl" \
"$API_DIR/contacts.hurl"
log "All tests passed."
+43
View File
@@ -0,0 +1,43 @@
# =============================================================
# OxiCloud – First-time admin setup
# =============================================================
# Creates the initial admin account. Only works once: the
# endpoint is disabled after the first admin exists.
#
# Run:
# hurl --variables-file tests/api/hurl.vars --test tests/api/setup.hurl
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 – Create the first admin account
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/setup
Content-Type: application/json
{
"username": "{{username}}",
"email": "{{email}}",
"password": "{{password}}"
}
HTTP 201
[Asserts]
jsonpath "$.username" == "{{username}}"
jsonpath "$.email" == "{{email}}"
jsonpath "$.role" == "admin"
# ─────────────────────────────────────────────────────────────
# Step 2 – Verify the endpoint is now disabled (admin exists)
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/setup
Content-Type: application/json
{
"username": "another",
"email": "another@example.com",
"password": "irrelevant"
}
HTTP 403
[Asserts]
jsonpath "$.error_type" == "SystemAlreadyInitialized"