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"
+18
View File
@@ -0,0 +1,18 @@
# Shared test-server environment variables.
# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node).
# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here —
# each test suite sets those to avoid port/directory conflicts.
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
OXICLOUD_STATIC_PATH=./static
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
OXICLOUD_ENABLE_AUTH=true
OXICLOUD_ENABLE_TRASH=true
OXICLOUD_ENABLE_SEARCH=true
OXICLOUD_ENABLE_FILE_SHARING=true
OXICLOUD_ENABLE_MUSIC=true
OXICLOUD_EXPOSE_SYSTEM_USERS=true
OXICLOUD_WOPI_ENABLED=false
OXICLOUD_OIDC_ENABLED=false
RUST_LOG=info
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
COMPOSE_FILE="$(dirname "$0")/docker-compose.test.yml"
wait_for_port() {
local host="$1" port="$2" timeout="${3:-30}"
local deadline=$(( $(date +%s) + timeout ))
until nc -z "$host" "$port" 2>/dev/null; do
[[ $(date +%s) -ge $deadline ]] && echo "Timeout waiting for $host:$port" >&2 && exit 1
sleep 0.5
done
}
echo "[setup] Starting test postgres..."
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
docker compose -f "$COMPOSE_FILE" up -d
echo "[setup] Waiting for postgres on port 5433..."
wait_for_port 127.0.0.1 5433
echo "[setup] Postgres is ready."
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
COMPOSE_FILE="$(dirname "$0")/docker-compose.test.yml"
echo "[teardown] Stopping test postgres..."
docker compose -f "$COMPOSE_FILE" down -v
+3
View File
@@ -0,0 +1,3 @@
end to end tests via playwright
+2 -2
View File
@@ -4,9 +4,9 @@
"description": "",
"main": "index.js",
"scripts": {
"pretest": "node -e \"(async()=>{await require('./spawn-db.js')()})().catch(e=>{console.error(e);process.exit(1)})\"",
"pretest": "bash ../common/spawn-db.sh",
"test": "npx playwright test",
"posttest": "node -e \"(async()=>{await require('./stop-db.js')()})().catch(e=>{console.error(e);process.exit(1)})\""
"posttest": "bash ../common/stop-db.sh"
},
"keywords": [],
"author": "",
+19 -12
View File
@@ -1,4 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
/** Parse a KEY=VALUE env file, skipping blank lines and comments. */
function loadEnv(filePath: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx === -1) continue;
env[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
}
return env;
}
const commonEnv = loadEnv(path.join(__dirname, '../common/server.env'));
export default defineConfig({
testDir: './scenarios',
@@ -39,19 +56,9 @@ export default defineConfig({
stdout: 'pipe',
stderr: 'pipe',
env: {
DATABASE_URL: 'postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test',
OXICLOUD_SERVER_PORT: 8087,
OXICLOUD_DB_CONNECTION_STRING: 'postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test',
...commonEnv,
OXICLOUD_SERVER_PORT: '8087',
OXICLOUD_STORAGE_PATH: './tests/e2e/storage',
OXICLOUD_STATIC_PATH: './static',
OXICLOUD_JWT_SECRET: 'test-secret-do-not-use-in-prod-minimum-32-chars',
OXICLOUD_ENABLE_AUTH: 'true',
OXICLOUD_ENABLE_TRASH: 'true',
OXICLOUD_ENABLE_SEARCH: 'true',
OXICLOUD_ENABLE_FILE_SHARING: 'true',
OXICLOUD_WOPI_ENABLED: 'false',
OXICLOUD_OIDC_ENABLED: 'false',
RUST_LOG: 'warn',
},
},
});
-31
View File
@@ -1,31 +0,0 @@
const { execSync, spawnSync } = require('child_process');
const net = require('net');
const path = require('path');
const COMPOSE_FILE = path.join(__dirname, 'docker-compose.test.yml');
const CMD = `docker compose -f ${COMPOSE_FILE}`;
function waitForPort(host, port, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
function attempt() {
const sock = net.connect(port, host);
sock.once('connect', () => { sock.destroy(); resolve(); });
sock.once('error', () => {
sock.destroy();
if (Date.now() >= deadline) return reject(new Error(`Timeout waiting for ${host}:${port}`));
setTimeout(attempt, 500);
});
}
attempt();
});
}
module.exports = async function globalSetup() {
console.log('[setup] Starting test postgres (database is empty) ...');
execSync(`${CMD} down`, { stdio: 'inherit' });
execSync(`${CMD} up -d`, { stdio: 'inherit' });
console.log('[setup] Waiting for postgres on port 5433...');
await waitForPort('127.0.0.1', 5433);
console.log('[setup] Postgres is ready.');
};
-9
View File
@@ -1,9 +0,0 @@
const { execSync } = require('child_process');
const path = require('path');
const COMPOSE_FILE = path.join(__dirname, 'docker-compose.test.yml');
module.exports = async function globalTeardown() {
console.log('[teardown] Stopping test postgres...');
execSync(`docker compose -f ${COMPOSE_FILE} down -v`, { stdio: 'inherit' });
};