From b4a33abd715a86ea3baaef5e7403e898a1023da9 Mon Sep 17 00:00:00 2001 From: Andreas Brenner Date: Thu, 14 May 2026 19:04:03 +0200 Subject: [PATCH] ANW-20 hurl HTTP contract-test harness --- README.md | 22 +++++- scripts/ci.sh | 3 +- tests/fixtures/vault/.obsidian/workspace.json | 1 + tests/fixtures/vault/.trash/discarded.md | 3 + tests/fixtures/vault/Projects-old/legacy.md | 6 ++ .../fixtures/vault/Projects/PDR-001-intro.md | 6 ++ .../vault/Projects/PDR-002-followup.md | 6 ++ tests/fixtures/vault/Projects/alpha.md | 7 ++ tests/fixtures/vault/Projects/anwesen.md | 7 ++ tests/fixtures/vault/README-fixtures.md | 22 ++++++ tests/fixtures/vault/README.md | 6 ++ tests/fixtures/vault/drift/list-tag.md | 5 ++ tests/fixtures/vault/drift/scalar-tag.md | 5 ++ tests/fixtures/vault/events/a.md | 5 ++ tests/fixtures/vault/events/b.md | 5 ++ tests/fixtures/vault/events/c.md | 7 ++ tests/fixtures/vault/nested/author-info.md | 6 ++ tests/fixtures/vault/no-frontmatter.md | 1 + tests/hurl/health/health_shape.hurl | 14 ++++ tests/hurl/notes/folder_index.hurl | 13 ++++ tests/hurl/notes/folder_index_not_found.hurl | 3 + tests/hurl/notes/folder_index_root.hurl | 11 +++ tests/hurl/notes/get_one.hurl | 16 +++++ tests/hurl/notes/get_one_accept_markdown.hurl | 9 +++ tests/hurl/notes/get_one_dotdot_400.hurl | 5 ++ tests/hurl/notes/get_one_etag_304.hurl | 9 +++ tests/hurl/notes/get_one_etag_mismatch.hurl | 6 ++ tests/hurl/notes/get_one_no_frontmatter.hurl | 8 +++ tests/hurl/notes/get_one_not_found.hurl | 3 + tests/hurl/query/all.hurl | 6 ++ tests/hurl/query/anw_limit.hurl | 7 ++ .../hurl/query/anw_path_segment_boundary.hurl | 8 +++ tests/hurl/query/anw_recursive_false.hurl | 10 +++ tests/hurl/query/bad_exists_400.hurl | 3 + tests/hurl/query/bad_regex_400.hurl | 3 + tests/hurl/query/comma_escape.hurl | 6 ++ .../eq_bare_unifies_scalar_and_list.hurl | 8 +++ tests/hurl/query/eq_scalar.hurl | 10 +++ tests/hurl/query/exists_false.hurl | 7 ++ tests/hurl/query/exists_true.hurl | 7 ++ tests/hurl/query/in.hurl | 7 ++ tests/hurl/query/nested_dotted_key.hurl | 8 +++ tests/hurl/query/not.hurl | 6 ++ tests/hurl/query/prefix.hurl | 6 ++ tests/hurl/query/range_dates.hurl | 6 ++ tests/hurl/query/range_datetime.hurl | 7 ++ tests/hurl/query/regex.hurl | 7 ++ .../hurl/query/unknown_control_param_400.hurl | 3 + tests/hurl/query/unknown_operator_400.hurl | 3 + tests/run-hurl.sh | 69 +++++++++++++++++++ 50 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/vault/.obsidian/workspace.json create mode 100644 tests/fixtures/vault/.trash/discarded.md create mode 100644 tests/fixtures/vault/Projects-old/legacy.md create mode 100644 tests/fixtures/vault/Projects/PDR-001-intro.md create mode 100644 tests/fixtures/vault/Projects/PDR-002-followup.md create mode 100644 tests/fixtures/vault/Projects/alpha.md create mode 100644 tests/fixtures/vault/Projects/anwesen.md create mode 100644 tests/fixtures/vault/README-fixtures.md create mode 100644 tests/fixtures/vault/README.md create mode 100644 tests/fixtures/vault/drift/list-tag.md create mode 100644 tests/fixtures/vault/drift/scalar-tag.md create mode 100644 tests/fixtures/vault/events/a.md create mode 100644 tests/fixtures/vault/events/b.md create mode 100644 tests/fixtures/vault/events/c.md create mode 100644 tests/fixtures/vault/nested/author-info.md create mode 100644 tests/fixtures/vault/no-frontmatter.md create mode 100644 tests/hurl/health/health_shape.hurl create mode 100644 tests/hurl/notes/folder_index.hurl create mode 100644 tests/hurl/notes/folder_index_not_found.hurl create mode 100644 tests/hurl/notes/folder_index_root.hurl create mode 100644 tests/hurl/notes/get_one.hurl create mode 100644 tests/hurl/notes/get_one_accept_markdown.hurl create mode 100644 tests/hurl/notes/get_one_dotdot_400.hurl create mode 100644 tests/hurl/notes/get_one_etag_304.hurl create mode 100644 tests/hurl/notes/get_one_etag_mismatch.hurl create mode 100644 tests/hurl/notes/get_one_no_frontmatter.hurl create mode 100644 tests/hurl/notes/get_one_not_found.hurl create mode 100644 tests/hurl/query/all.hurl create mode 100644 tests/hurl/query/anw_limit.hurl create mode 100644 tests/hurl/query/anw_path_segment_boundary.hurl create mode 100644 tests/hurl/query/anw_recursive_false.hurl create mode 100644 tests/hurl/query/bad_exists_400.hurl create mode 100644 tests/hurl/query/bad_regex_400.hurl create mode 100644 tests/hurl/query/comma_escape.hurl create mode 100644 tests/hurl/query/eq_bare_unifies_scalar_and_list.hurl create mode 100644 tests/hurl/query/eq_scalar.hurl create mode 100644 tests/hurl/query/exists_false.hurl create mode 100644 tests/hurl/query/exists_true.hurl create mode 100644 tests/hurl/query/in.hurl create mode 100644 tests/hurl/query/nested_dotted_key.hurl create mode 100644 tests/hurl/query/not.hurl create mode 100644 tests/hurl/query/prefix.hurl create mode 100644 tests/hurl/query/range_dates.hurl create mode 100644 tests/hurl/query/range_datetime.hurl create mode 100644 tests/hurl/query/regex.hurl create mode 100644 tests/hurl/query/unknown_control_param_400.hurl create mode 100644 tests/hurl/query/unknown_operator_400.hurl create mode 100755 tests/run-hurl.sh diff --git a/README.md b/README.md index 4cde988..f94eb22 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,29 @@ over env. ## Development ``` -scripts/ci.sh # fmt + clippy + test +scripts/ci.sh # fmt + clippy + test + hurl contract harness ``` +### HTTP contract tests + +End-to-end HTTP behaviour is verified by [hurl](https://hurl.dev/) per +ADR-008. Install once: + +``` +# Ubuntu / Debian +curl -sSL -o /tmp/hurl.deb https://github.com/Orange-OpenSource/hurl/releases/download/8.0.0/hurl_8.0.0_amd64.deb +sudo dpkg -i /tmp/hurl.deb + +# Pinned at hurl 8.0.0. Other recent 8.x releases also work but are not +# what CI runs. +``` + +Then `scripts/ci.sh` -- or `tests/run-hurl.sh` to run just the contract +suite (it builds the debug binary if missing, boots `anwesen serve` on +`127.0.0.1:18086`, waits for `/health`, runs every `tests/hurl/**/*.hurl`, +and tears the daemon down on exit). Override the port with +`ANWESEN_TEST_PORT=` if 18086 clashes locally. + ## License Dual-licensed under MIT or Apache-2.0 at your option. diff --git a/scripts/ci.sh b/scripts/ci.sh index 0f6de53..7499183 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Local CI entrypoint: fmt / clippy / test. +# Local CI entrypoint: fmt / clippy / test / hurl contract harness. # Mirrors what a forge-side runner would execute. Run from the repo root. set -euo pipefail @@ -7,3 +7,4 @@ set -euo pipefail cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo test --all +tests/run-hurl.sh diff --git a/tests/fixtures/vault/.obsidian/workspace.json b/tests/fixtures/vault/.obsidian/workspace.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/vault/.obsidian/workspace.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/vault/.trash/discarded.md b/tests/fixtures/vault/.trash/discarded.md new file mode 100644 index 0000000..8281e0a --- /dev/null +++ b/tests/fixtures/vault/.trash/discarded.md @@ -0,0 +1,3 @@ +--- +--- +Should be invisible to the daemon. diff --git a/tests/fixtures/vault/Projects-old/legacy.md b/tests/fixtures/vault/Projects-old/legacy.md new file mode 100644 index 0000000..13fcbc6 --- /dev/null +++ b/tests/fixtures/vault/Projects-old/legacy.md @@ -0,0 +1,6 @@ +--- +tags: [legacy] +status: archived +--- +Legacy project under a sibling-prefix folder used to verify __anw-path +segment-boundary anchoring (Projects vs Projects-old). diff --git a/tests/fixtures/vault/Projects/PDR-001-intro.md b/tests/fixtures/vault/Projects/PDR-001-intro.md new file mode 100644 index 0000000..1389588 --- /dev/null +++ b/tests/fixtures/vault/Projects/PDR-001-intro.md @@ -0,0 +1,6 @@ +--- +kind: PDR +num: 1 +title: PDR-001 Intro +--- +First PDR. diff --git a/tests/fixtures/vault/Projects/PDR-002-followup.md b/tests/fixtures/vault/Projects/PDR-002-followup.md new file mode 100644 index 0000000..68f1b46 --- /dev/null +++ b/tests/fixtures/vault/Projects/PDR-002-followup.md @@ -0,0 +1,6 @@ +--- +kind: PDR +num: 2 +title: PDR-002 Followup +--- +Second PDR. diff --git a/tests/fixtures/vault/Projects/alpha.md b/tests/fixtures/vault/Projects/alpha.md new file mode 100644 index 0000000..fe75bc8 --- /dev/null +++ b/tests/fixtures/vault/Projects/alpha.md @@ -0,0 +1,7 @@ +--- +tags: [alpha, project] +status: draft +version: 2 +deprecated: true +--- +Alpha project. diff --git a/tests/fixtures/vault/Projects/anwesen.md b/tests/fixtures/vault/Projects/anwesen.md new file mode 100644 index 0000000..89c61d6 --- /dev/null +++ b/tests/fixtures/vault/Projects/anwesen.md @@ -0,0 +1,7 @@ +--- +tags: [anwesen, project] +status: active +version: 1 +deprecated: false +--- +Anwesen project root. diff --git a/tests/fixtures/vault/README-fixtures.md b/tests/fixtures/vault/README-fixtures.md new file mode 100644 index 0000000..2083d91 --- /dev/null +++ b/tests/fixtures/vault/README-fixtures.md @@ -0,0 +1,22 @@ +--- +purpose: fixture-guide +--- +# Fixture vault + +Hand-crafted for the hurl contract harness; every note here exists to +exercise a specific clause of the [[User Manual]]. Touch with care; many +`*.hurl` files match exact body bytes or count results. + +## Coverage map + +| Note(s) | Exercised | +|---|---| +| `README.md` | Top-level `application/json` read, ETag, `Accept: text/markdown`, `If-None-Match` 304. | +| `no-frontmatter.md` | Notes without a frontmatter block. | +| `Projects/anwesen.md`, `Projects/alpha.md` | bare-Eq, `__not`, `__exists`, `__in`, `__all` on tags. | +| `Projects/PDR-001-intro.md`, `Projects/PDR-002-followup.md` | `__regex`, `__prefix`, `kind=PDR` grouping. | +| `Projects-old/legacy.md` | `__anw-path` segment-boundary check (`Projects` vs `Projects-old`). | +| `events/a.md`, `events/b.md`, `events/c.md` | `__gt`/`__gte`/`__lt`/`__lte` on ISO-8601 dates and RFC 3339 datetimes. | +| `drift/scalar-tag.md`, `drift/list-tag.md` | Bare-Eq scalar/list unification carve-out per ADR-005. | +| `nested/author-info.md` | Dotted nested-key predicates (`author.name=...`). | +| `.obsidian/`, `.trash/` | Dot-directory ignore rule. | diff --git a/tests/fixtures/vault/README.md b/tests/fixtures/vault/README.md new file mode 100644 index 0000000..0dc73e3 --- /dev/null +++ b/tests/fixtures/vault/README.md @@ -0,0 +1,6 @@ +--- +tags: [demo, readme] +date: 2026-05-01 +status: published +--- +Fixture vault README. Used by the hurl contract harness. diff --git a/tests/fixtures/vault/drift/list-tag.md b/tests/fixtures/vault/drift/list-tag.md new file mode 100644 index 0000000..f4761f0 --- /dev/null +++ b/tests/fixtures/vault/drift/list-tag.md @@ -0,0 +1,5 @@ +--- +tag: [python, go] +--- +List 'tag' value -- pairs with scalar-tag.md to exercise bare-Eq +scalar/list unification. diff --git a/tests/fixtures/vault/drift/scalar-tag.md b/tests/fixtures/vault/drift/scalar-tag.md new file mode 100644 index 0000000..bc587c7 --- /dev/null +++ b/tests/fixtures/vault/drift/scalar-tag.md @@ -0,0 +1,5 @@ +--- +tag: python +--- +Bare scalar 'tag' value -- pairs with list-tag.md to exercise bare-Eq +scalar/list unification. diff --git a/tests/fixtures/vault/events/a.md b/tests/fixtures/vault/events/a.md new file mode 100644 index 0000000..45191b6 --- /dev/null +++ b/tests/fixtures/vault/events/a.md @@ -0,0 +1,5 @@ +--- +date: 2026-01-15 +title: January event +--- +Earliest event. diff --git a/tests/fixtures/vault/events/b.md b/tests/fixtures/vault/events/b.md new file mode 100644 index 0000000..4c8eeb1 --- /dev/null +++ b/tests/fixtures/vault/events/b.md @@ -0,0 +1,5 @@ +--- +date: 2026-03-22 +title: March event +--- +Mid event. diff --git a/tests/fixtures/vault/events/c.md b/tests/fixtures/vault/events/c.md new file mode 100644 index 0000000..3012995 --- /dev/null +++ b/tests/fixtures/vault/events/c.md @@ -0,0 +1,7 @@ +--- +date: 2026-05-14 +when: 2026-05-14T10:14:22Z +title: May event +--- +Latest event; carries both a date and an RFC 3339 datetime to exercise +both coerced types. diff --git a/tests/fixtures/vault/nested/author-info.md b/tests/fixtures/vault/nested/author-info.md new file mode 100644 index 0000000..e041351 --- /dev/null +++ b/tests/fixtures/vault/nested/author-info.md @@ -0,0 +1,6 @@ +--- +author: + name: brn + role: engineer +--- +Nested-key exercise for the `author.name=...` dotted-key path. diff --git a/tests/fixtures/vault/no-frontmatter.md b/tests/fixtures/vault/no-frontmatter.md new file mode 100644 index 0000000..faaf8a4 --- /dev/null +++ b/tests/fixtures/vault/no-frontmatter.md @@ -0,0 +1 @@ +A note with no frontmatter block, just body text. diff --git a/tests/hurl/health/health_shape.hurl b/tests/hurl/health/health_shape.hurl new file mode 100644 index 0000000..e40e715 --- /dev/null +++ b/tests/hurl/health/health_shape.hurl @@ -0,0 +1,14 @@ +# GET /health returns 200, JSON, and the shape promised by the User Manual. +GET {{host}}/health +HTTP 200 +[Asserts] +header "Content-Type" startsWith "application/json" +jsonpath "$.note_count" exists +jsonpath "$.note_count" >= 14 +jsonpath "$.watcher_state" matches "^(running|degraded)$" +jsonpath "$.in_flight_rescan" isBoolean +jsonpath "$.vault_path" exists +jsonpath "$.supervisor.restarts.vault_scanner" exists +jsonpath "$.supervisor.restarts.filesystem_watcher" exists +jsonpath "$.supervisor.restarts.index_writer" exists +jsonpath "$.supervisor.restarts.http_server" exists diff --git a/tests/hurl/notes/folder_index.hurl b/tests/hurl/notes/folder_index.hurl new file mode 100644 index 0000000..450a433 --- /dev/null +++ b/tests/hurl/notes/folder_index.hurl @@ -0,0 +1,13 @@ +# Trailing slash is a folder listing -- immediate children only. +GET {{host}}/notes/Projects/ +HTTP 200 +[Asserts] +header "Content-Type" startsWith "application/json" +jsonpath "$.path" == "Projects" +jsonpath "$.entries" count == 4 +jsonpath "$.entries[0].name" == "PDR-001-intro.md" +jsonpath "$.entries[0].type" == "file" +jsonpath "$.entries[0].size" > 0 +jsonpath "$.entries[1].name" == "PDR-002-followup.md" +jsonpath "$.entries[2].name" == "alpha.md" +jsonpath "$.entries[3].name" == "anwesen.md" diff --git a/tests/hurl/notes/folder_index_not_found.hurl b/tests/hurl/notes/folder_index_not_found.hurl new file mode 100644 index 0000000..0f5a37f --- /dev/null +++ b/tests/hurl/notes/folder_index_not_found.hurl @@ -0,0 +1,3 @@ +# Missing folder returns 404. +GET {{host}}/notes/no-such-folder/ +HTTP 404 diff --git a/tests/hurl/notes/folder_index_root.hurl b/tests/hurl/notes/folder_index_root.hurl new file mode 100644 index 0000000..506c332 --- /dev/null +++ b/tests/hurl/notes/folder_index_root.hurl @@ -0,0 +1,11 @@ +# /notes/ is the vault root listing; dot-directories are invisible. +GET {{host}}/notes/ +HTTP 200 +[Asserts] +jsonpath "$.path" == "" +jsonpath "$.entries[*].name" not contains ".obsidian" +jsonpath "$.entries[*].name" not contains ".trash" +jsonpath "$.entries[*].name" contains "Projects" +jsonpath "$.entries[*].name" contains "Projects-old" +jsonpath "$.entries[*].name" contains "events" +jsonpath "$.entries[*].name" contains "README.md" diff --git a/tests/hurl/notes/get_one.hurl b/tests/hurl/notes/get_one.hurl new file mode 100644 index 0000000..95dcc42 --- /dev/null +++ b/tests/hurl/notes/get_one.hurl @@ -0,0 +1,16 @@ +# GET /notes/ returns the documented JSON shape. +GET {{host}}/notes/README.md +HTTP 200 +[Asserts] +header "Content-Type" startsWith "application/json" +header "ETag" exists +jsonpath "$.path" == "README.md" +jsonpath "$.frontmatter.status" == "published" +jsonpath "$.frontmatter.tags" count == 2 +jsonpath "$.frontmatter.tags[0]" == "demo" +jsonpath "$.frontmatter.tags[1]" == "readme" +jsonpath "$.frontmatter.date" == "2026-05-01" +jsonpath "$.body" contains "Fixture vault README" +jsonpath "$.size" > 0 +jsonpath "$.etag" matches "^\"[0-9a-f]{64}\"$" +jsonpath "$.last_modified" matches "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" diff --git a/tests/hurl/notes/get_one_accept_markdown.hurl b/tests/hurl/notes/get_one_accept_markdown.hurl new file mode 100644 index 0000000..7c41525 --- /dev/null +++ b/tests/hurl/notes/get_one_accept_markdown.hurl @@ -0,0 +1,9 @@ +# Accept: text/markdown returns the raw file bytes verbatim. +GET {{host}}/notes/README.md +Accept: text/markdown +HTTP 200 +[Asserts] +header "Content-Type" startsWith "text/markdown" +header "ETag" exists +body startsWith "---\ntags: [demo, readme]\ndate: 2026-05-01\nstatus: published\n---\n" +body contains "Fixture vault README. Used by the hurl contract harness." diff --git a/tests/hurl/notes/get_one_dotdot_400.hurl b/tests/hurl/notes/get_one_dotdot_400.hurl new file mode 100644 index 0000000..0b76028 --- /dev/null +++ b/tests/hurl/notes/get_one_dotdot_400.hurl @@ -0,0 +1,5 @@ +# .. path segments are rejected with 400. Use percent-encoding to bypass +# the HTTP client's URL normalization; curl/hurl collapse a bare ".." +# before the request goes out. +GET {{host}}/notes/%2E%2E/etc/passwd +HTTP 400 diff --git a/tests/hurl/notes/get_one_etag_304.hurl b/tests/hurl/notes/get_one_etag_304.hurl new file mode 100644 index 0000000..49e87dc --- /dev/null +++ b/tests/hurl/notes/get_one_etag_304.hurl @@ -0,0 +1,9 @@ +# Chain: fetch ETag, then send If-None-Match -> 304. +GET {{host}}/notes/README.md +HTTP 200 +[Captures] +etag: header "ETag" + +GET {{host}}/notes/README.md +If-None-Match: {{etag}} +HTTP 304 diff --git a/tests/hurl/notes/get_one_etag_mismatch.hurl b/tests/hurl/notes/get_one_etag_mismatch.hurl new file mode 100644 index 0000000..d252c6f --- /dev/null +++ b/tests/hurl/notes/get_one_etag_mismatch.hurl @@ -0,0 +1,6 @@ +# A stale ETag returns the full body (not 304). +GET {{host}}/notes/README.md +If-None-Match: "stale0000000000000000000000000000000000000000000000000000000000" +HTTP 200 +[Asserts] +jsonpath "$.path" == "README.md" diff --git a/tests/hurl/notes/get_one_no_frontmatter.hurl b/tests/hurl/notes/get_one_no_frontmatter.hurl new file mode 100644 index 0000000..15e4103 --- /dev/null +++ b/tests/hurl/notes/get_one_no_frontmatter.hurl @@ -0,0 +1,8 @@ +# A note without a frontmatter block still serves; frontmatter is {}. +GET {{host}}/notes/no-frontmatter.md +HTTP 200 +[Asserts] +jsonpath "$.path" == "no-frontmatter.md" +jsonpath "$.frontmatter" exists +jsonpath "$.frontmatter.tags" not exists +jsonpath "$.body" contains "no frontmatter block" diff --git a/tests/hurl/notes/get_one_not_found.hurl b/tests/hurl/notes/get_one_not_found.hurl new file mode 100644 index 0000000..d7a23f1 --- /dev/null +++ b/tests/hurl/notes/get_one_not_found.hurl @@ -0,0 +1,3 @@ +# Missing path returns 404. +GET {{host}}/notes/does-not-exist.md +HTTP 404 diff --git a/tests/hurl/query/all.hurl b/tests/hurl/query/all.hurl new file mode 100644 index 0000000..0633479 --- /dev/null +++ b/tests/hurl/query/all.hurl @@ -0,0 +1,6 @@ +# __all: array contains all listed values. +GET {{host}}/query?tags__all=anwesen,project +HTTP 200 +[Asserts] +jsonpath "$.total" == 1 +jsonpath "$.results[0].path" == "Projects/anwesen.md" diff --git a/tests/hurl/query/anw_limit.hurl b/tests/hurl/query/anw_limit.hurl new file mode 100644 index 0000000..0d7b295 --- /dev/null +++ b/tests/hurl/query/anw_limit.hurl @@ -0,0 +1,7 @@ +# __anw-limit caps results; total still reflects the full match count. +GET {{host}}/query?__anw-limit=1&__anw-path=Projects +HTTP 200 +[Asserts] +jsonpath "$.results" count == 1 +jsonpath "$.total" == 4 +jsonpath "$.truncated" == true diff --git a/tests/hurl/query/anw_path_segment_boundary.hurl b/tests/hurl/query/anw_path_segment_boundary.hurl new file mode 100644 index 0000000..c9e4f89 --- /dev/null +++ b/tests/hurl/query/anw_path_segment_boundary.hurl @@ -0,0 +1,8 @@ +# __anw-path=Projects must NOT match Projects-old/legacy.md -- segment- +# boundary anchoring per the ANW-22 fix. +GET {{host}}/query?__anw-path=Projects +HTTP 200 +[Asserts] +jsonpath "$.results[*].path" contains "Projects/anwesen.md" +jsonpath "$.results[*].path" contains "Projects/alpha.md" +jsonpath "$.results[*].path" not contains "Projects-old/legacy.md" diff --git a/tests/hurl/query/anw_recursive_false.hurl b/tests/hurl/query/anw_recursive_false.hurl new file mode 100644 index 0000000..9eaa548 --- /dev/null +++ b/tests/hurl/query/anw_recursive_false.hurl @@ -0,0 +1,10 @@ +# __anw-recursive=false restricts to direct children of __anw-path. +GET {{host}}/query?__anw-path=Projects&__anw-recursive=false +HTTP 200 +[Asserts] +# Four notes live directly under Projects/; none under deeper folders here. +jsonpath "$.total" == 4 +jsonpath "$.results[*].path" contains "Projects/anwesen.md" +jsonpath "$.results[*].path" contains "Projects/alpha.md" +jsonpath "$.results[*].path" contains "Projects/PDR-001-intro.md" +jsonpath "$.results[*].path" contains "Projects/PDR-002-followup.md" diff --git a/tests/hurl/query/bad_exists_400.hurl b/tests/hurl/query/bad_exists_400.hurl new file mode 100644 index 0000000..edd69f8 --- /dev/null +++ b/tests/hurl/query/bad_exists_400.hurl @@ -0,0 +1,3 @@ +# __exists value that isn't a bool is rejected with 400. +GET {{host}}/query?deprecated__exists=maybe +HTTP 400 diff --git a/tests/hurl/query/bad_regex_400.hurl b/tests/hurl/query/bad_regex_400.hurl new file mode 100644 index 0000000..9749b4d --- /dev/null +++ b/tests/hurl/query/bad_regex_400.hurl @@ -0,0 +1,3 @@ +# Malformed regex value is rejected with 400 at parse time. +GET {{host}}/query?title__regex=(unclosed +HTTP 400 diff --git a/tests/hurl/query/comma_escape.hurl b/tests/hurl/query/comma_escape.hurl new file mode 100644 index 0000000..71e01de --- /dev/null +++ b/tests/hurl/query/comma_escape.hurl @@ -0,0 +1,6 @@ +# A literal comma inside an __in value goes in percent-encoded as %2C. +# Smoke: confirm the request shape is accepted (no match is fine). +GET {{host}}/query?title__in=PDR-001%2CIntro +HTTP 200 +[Asserts] +jsonpath "$.total" == 0 diff --git a/tests/hurl/query/eq_bare_unifies_scalar_and_list.hurl b/tests/hurl/query/eq_bare_unifies_scalar_and_list.hurl new file mode 100644 index 0000000..fbfe176 --- /dev/null +++ b/tests/hurl/query/eq_bare_unifies_scalar_and_list.hurl @@ -0,0 +1,8 @@ +# Bare-Eq scalar/list unification per ADR-005: tag=python matches both +# `tag: python` and `tag: [python, go]`. +GET {{host}}/query?tag=python +HTTP 200 +[Asserts] +jsonpath "$.total" == 2 +jsonpath "$.results[*].path" contains "drift/scalar-tag.md" +jsonpath "$.results[*].path" contains "drift/list-tag.md" diff --git a/tests/hurl/query/eq_scalar.hurl b/tests/hurl/query/eq_scalar.hurl new file mode 100644 index 0000000..3f40433 --- /dev/null +++ b/tests/hurl/query/eq_scalar.hurl @@ -0,0 +1,10 @@ +# Bare equality on a scalar value. +GET {{host}}/query?status=active +HTTP 200 +[Asserts] +jsonpath "$.results" count == 1 +jsonpath "$.results[0].path" == "Projects/anwesen.md" +jsonpath "$.results[0].frontmatter.status" == "active" +jsonpath "$.results[0].body" not exists +jsonpath "$.total" == 1 +jsonpath "$.truncated" == false diff --git a/tests/hurl/query/exists_false.hurl b/tests/hurl/query/exists_false.hurl new file mode 100644 index 0000000..56d275c --- /dev/null +++ b/tests/hurl/query/exists_false.hurl @@ -0,0 +1,7 @@ +# __exists=false: matches every note that lacks the key. +GET {{host}}/query?deprecated__exists=false&kind__exists=false&author__exists=false&date__exists=false&purpose__exists=false&tag__exists=false&when__exists=false&tags__exists=false&title__exists=false&status__exists=false +HTTP 200 +[Asserts] +# no-frontmatter.md is the only note with no keys at all. +jsonpath "$.total" == 1 +jsonpath "$.results[0].path" == "no-frontmatter.md" diff --git a/tests/hurl/query/exists_true.hurl b/tests/hurl/query/exists_true.hurl new file mode 100644 index 0000000..d21435a --- /dev/null +++ b/tests/hurl/query/exists_true.hurl @@ -0,0 +1,7 @@ +# __exists=true: matches every note that defines the key. +GET {{host}}/query?deprecated__exists=true +HTTP 200 +[Asserts] +jsonpath "$.total" == 2 +jsonpath "$.results[*].path" contains "Projects/anwesen.md" +jsonpath "$.results[*].path" contains "Projects/alpha.md" diff --git a/tests/hurl/query/in.hurl b/tests/hurl/query/in.hurl new file mode 100644 index 0000000..156925f --- /dev/null +++ b/tests/hurl/query/in.hurl @@ -0,0 +1,7 @@ +# __in: one-of, against a list field. +GET {{host}}/query?tags__in=alpha,anwesen +HTTP 200 +[Asserts] +jsonpath "$.total" == 2 +jsonpath "$.results[*].path" contains "Projects/anwesen.md" +jsonpath "$.results[*].path" contains "Projects/alpha.md" diff --git a/tests/hurl/query/nested_dotted_key.hurl b/tests/hurl/query/nested_dotted_key.hurl new file mode 100644 index 0000000..5db20e9 --- /dev/null +++ b/tests/hurl/query/nested_dotted_key.hurl @@ -0,0 +1,8 @@ +# Nested key addressed via dotted path: author.name=brn. +GET {{host}}/query?author.name=brn +HTTP 200 +[Asserts] +jsonpath "$.total" == 1 +jsonpath "$.results[0].path" == "nested/author-info.md" +jsonpath "$.results[0].frontmatter.author.name" == "brn" +jsonpath "$.results[0].frontmatter.author.role" == "engineer" diff --git a/tests/hurl/query/not.hurl b/tests/hurl/query/not.hurl new file mode 100644 index 0000000..9ffd531 --- /dev/null +++ b/tests/hurl/query/not.hurl @@ -0,0 +1,6 @@ +# __not: negation on a scalar. +GET {{host}}/query?status__not=draft&kind__exists=false +HTTP 200 +[Asserts] +jsonpath "$.results[*].path" contains "Projects/anwesen.md" +jsonpath "$.results[*].path" not contains "Projects/alpha.md" diff --git a/tests/hurl/query/prefix.hurl b/tests/hurl/query/prefix.hurl new file mode 100644 index 0000000..b79aa36 --- /dev/null +++ b/tests/hurl/query/prefix.hurl @@ -0,0 +1,6 @@ +# __prefix: string prefix match on a scalar. +GET {{host}}/query?title__prefix=PDR-001 +HTTP 200 +[Asserts] +jsonpath "$.total" == 1 +jsonpath "$.results[0].path" == "Projects/PDR-001-intro.md" diff --git a/tests/hurl/query/range_dates.hurl b/tests/hurl/query/range_dates.hurl new file mode 100644 index 0000000..2b50857 --- /dev/null +++ b/tests/hurl/query/range_dates.hurl @@ -0,0 +1,6 @@ +# __gte / __lte against ISO-8601 dates. +GET {{host}}/query?date__gte=2026-02-01&date__lte=2026-04-01 +HTTP 200 +[Asserts] +jsonpath "$.total" == 1 +jsonpath "$.results[0].path" == "events/b.md" diff --git a/tests/hurl/query/range_datetime.hurl b/tests/hurl/query/range_datetime.hurl new file mode 100644 index 0000000..935136f --- /dev/null +++ b/tests/hurl/query/range_datetime.hurl @@ -0,0 +1,7 @@ +# __gt against an RFC 3339 datetime; verifies coerced typed-date comparison. +GET {{host}}/query?when__gt=2026-05-14T00:00:00Z +HTTP 200 +[Asserts] +jsonpath "$.total" == 1 +jsonpath "$.results[0].path" == "events/c.md" +jsonpath "$.results[0].frontmatter.when" == "2026-05-14T10:14:22Z" diff --git a/tests/hurl/query/regex.hurl b/tests/hurl/query/regex.hurl new file mode 100644 index 0000000..fb9bbbb --- /dev/null +++ b/tests/hurl/query/regex.hurl @@ -0,0 +1,7 @@ +# __regex: anchored pattern on a scalar. +GET {{host}}/query?title__regex=^PDR-%5Cd%2B +HTTP 200 +[Asserts] +jsonpath "$.total" == 2 +jsonpath "$.results[*].path" contains "Projects/PDR-001-intro.md" +jsonpath "$.results[*].path" contains "Projects/PDR-002-followup.md" diff --git a/tests/hurl/query/unknown_control_param_400.hurl b/tests/hurl/query/unknown_control_param_400.hurl new file mode 100644 index 0000000..135f037 --- /dev/null +++ b/tests/hurl/query/unknown_control_param_400.hurl @@ -0,0 +1,3 @@ +# Unknown __anw- control parameter is rejected with 400. +GET {{host}}/query?__anw-bogus=1 +HTTP 400 diff --git a/tests/hurl/query/unknown_operator_400.hurl b/tests/hurl/query/unknown_operator_400.hurl new file mode 100644 index 0000000..4abd1ac --- /dev/null +++ b/tests/hurl/query/unknown_operator_400.hurl @@ -0,0 +1,3 @@ +# Unknown operator suffix is rejected with 400 (not silently ignored). +GET {{host}}/query?title__contians=foo +HTTP 400 diff --git a/tests/run-hurl.sh b/tests/run-hurl.sh new file mode 100755 index 0000000..d64c841 --- /dev/null +++ b/tests/run-hurl.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# End-to-end HTTP contract harness per [[ADR-008 HTTP Testing with hurl]]. +# +# Boots `anwesen serve` against `tests/fixtures/vault`, waits for /health to +# return 200, then runs `hurl --test` against every *.hurl under tests/hurl/. +# Propagates hurl's exit code; tears the daemon down on exit. +# +# Override PORT via ANWESEN_TEST_PORT if 18086 clashes locally. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VAULT="$ROOT/tests/fixtures/vault" +HOST="127.0.0.1" +PORT="${ANWESEN_TEST_PORT:-18086}" +BIN="${ANWESEN_BIN:-$ROOT/target/debug/anwesen}" + +if ! command -v hurl >/dev/null 2>&1; then + echo "hurl not found in PATH; see README for install" >&2 + exit 127 +fi + +if [[ ! -x "$BIN" ]]; then + echo "building debug anwesen binary" + (cd "$ROOT" && cargo build --quiet) +fi + +LOG="$(mktemp)" +"$BIN" serve --vault "$VAULT" --bind "$HOST:$PORT" --log-level warn >"$LOG" 2>&1 & +PID=$! +cleanup() { + if kill -0 "$PID" 2>/dev/null; then + kill "$PID" 2>/dev/null || true + wait "$PID" 2>/dev/null || true + fi + if [[ "${HURL_RC:-0}" -ne 0 && -s "$LOG" ]]; then + echo "--- anwesen serve log ---" >&2 + cat "$LOG" >&2 + fi + rm -f "$LOG" +} +trap cleanup EXIT + +# Wait up to ~10s for /health to come up. +ready=0 +for _ in $(seq 1 50); do + if curl -sf -o /dev/null "http://$HOST:$PORT/health"; then + ready=1 + break + fi + sleep 0.2 +done +if [[ $ready -ne 1 ]]; then + echo "anwesen did not become ready on http://$HOST:$PORT" >&2 + exit 1 +fi + +# Run every *.hurl. Glob into an array so we can fail loud if there are none. +shopt -s globstar nullglob +files=("$ROOT"/tests/hurl/**/*.hurl) +if [[ ${#files[@]} -eq 0 ]]; then + echo "no *.hurl files found under tests/hurl/" >&2 + exit 1 +fi + +set +e +hurl --test --variable "host=http://$HOST:$PORT" "${files[@]}" +HURL_RC=$? +set -e +exit "$HURL_RC"