ANW-20 hurl HTTP contract-test harness
This commit is contained in:
parent
ec650e6c66
commit
b4a33abd71
50 changed files with 415 additions and 2 deletions
22
README.md
22
README.md
|
|
@ -21,9 +21,29 @@ over env.
|
||||||
## Development
|
## 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=<n>` if 18086 clashes locally.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Dual-licensed under MIT or Apache-2.0 at your option.
|
Dual-licensed under MIT or Apache-2.0 at your option.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env bash
|
#!/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.
|
# Mirrors what a forge-side runner would execute. Run from the repo root.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -7,3 +7,4 @@ set -euo pipefail
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cargo clippy --all-targets --all-features -- -D warnings
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
cargo test --all
|
cargo test --all
|
||||||
|
tests/run-hurl.sh
|
||||||
|
|
|
||||||
1
tests/fixtures/vault/.obsidian/workspace.json
vendored
Normal file
1
tests/fixtures/vault/.obsidian/workspace.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
3
tests/fixtures/vault/.trash/discarded.md
vendored
Normal file
3
tests/fixtures/vault/.trash/discarded.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
---
|
||||||
|
Should be invisible to the daemon.
|
||||||
6
tests/fixtures/vault/Projects-old/legacy.md
vendored
Normal file
6
tests/fixtures/vault/Projects-old/legacy.md
vendored
Normal file
|
|
@ -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).
|
||||||
6
tests/fixtures/vault/Projects/PDR-001-intro.md
vendored
Normal file
6
tests/fixtures/vault/Projects/PDR-001-intro.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
kind: PDR
|
||||||
|
num: 1
|
||||||
|
title: PDR-001 Intro
|
||||||
|
---
|
||||||
|
First PDR.
|
||||||
6
tests/fixtures/vault/Projects/PDR-002-followup.md
vendored
Normal file
6
tests/fixtures/vault/Projects/PDR-002-followup.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
kind: PDR
|
||||||
|
num: 2
|
||||||
|
title: PDR-002 Followup
|
||||||
|
---
|
||||||
|
Second PDR.
|
||||||
7
tests/fixtures/vault/Projects/alpha.md
vendored
Normal file
7
tests/fixtures/vault/Projects/alpha.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
tags: [alpha, project]
|
||||||
|
status: draft
|
||||||
|
version: 2
|
||||||
|
deprecated: true
|
||||||
|
---
|
||||||
|
Alpha project.
|
||||||
7
tests/fixtures/vault/Projects/anwesen.md
vendored
Normal file
7
tests/fixtures/vault/Projects/anwesen.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
tags: [anwesen, project]
|
||||||
|
status: active
|
||||||
|
version: 1
|
||||||
|
deprecated: false
|
||||||
|
---
|
||||||
|
Anwesen project root.
|
||||||
22
tests/fixtures/vault/README-fixtures.md
vendored
Normal file
22
tests/fixtures/vault/README-fixtures.md
vendored
Normal file
|
|
@ -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. |
|
||||||
6
tests/fixtures/vault/README.md
vendored
Normal file
6
tests/fixtures/vault/README.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
tags: [demo, readme]
|
||||||
|
date: 2026-05-01
|
||||||
|
status: published
|
||||||
|
---
|
||||||
|
Fixture vault README. Used by the hurl contract harness.
|
||||||
5
tests/fixtures/vault/drift/list-tag.md
vendored
Normal file
5
tests/fixtures/vault/drift/list-tag.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
tag: [python, go]
|
||||||
|
---
|
||||||
|
List 'tag' value -- pairs with scalar-tag.md to exercise bare-Eq
|
||||||
|
scalar/list unification.
|
||||||
5
tests/fixtures/vault/drift/scalar-tag.md
vendored
Normal file
5
tests/fixtures/vault/drift/scalar-tag.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
tag: python
|
||||||
|
---
|
||||||
|
Bare scalar 'tag' value -- pairs with list-tag.md to exercise bare-Eq
|
||||||
|
scalar/list unification.
|
||||||
5
tests/fixtures/vault/events/a.md
vendored
Normal file
5
tests/fixtures/vault/events/a.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
date: 2026-01-15
|
||||||
|
title: January event
|
||||||
|
---
|
||||||
|
Earliest event.
|
||||||
5
tests/fixtures/vault/events/b.md
vendored
Normal file
5
tests/fixtures/vault/events/b.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
date: 2026-03-22
|
||||||
|
title: March event
|
||||||
|
---
|
||||||
|
Mid event.
|
||||||
7
tests/fixtures/vault/events/c.md
vendored
Normal file
7
tests/fixtures/vault/events/c.md
vendored
Normal file
|
|
@ -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.
|
||||||
6
tests/fixtures/vault/nested/author-info.md
vendored
Normal file
6
tests/fixtures/vault/nested/author-info.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
author:
|
||||||
|
name: brn
|
||||||
|
role: engineer
|
||||||
|
---
|
||||||
|
Nested-key exercise for the `author.name=...` dotted-key path.
|
||||||
1
tests/fixtures/vault/no-frontmatter.md
vendored
Normal file
1
tests/fixtures/vault/no-frontmatter.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
A note with no frontmatter block, just body text.
|
||||||
14
tests/hurl/health/health_shape.hurl
Normal file
14
tests/hurl/health/health_shape.hurl
Normal file
|
|
@ -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
|
||||||
13
tests/hurl/notes/folder_index.hurl
Normal file
13
tests/hurl/notes/folder_index.hurl
Normal file
|
|
@ -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"
|
||||||
3
tests/hurl/notes/folder_index_not_found.hurl
Normal file
3
tests/hurl/notes/folder_index_not_found.hurl
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Missing folder returns 404.
|
||||||
|
GET {{host}}/notes/no-such-folder/
|
||||||
|
HTTP 404
|
||||||
11
tests/hurl/notes/folder_index_root.hurl
Normal file
11
tests/hurl/notes/folder_index_root.hurl
Normal file
|
|
@ -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"
|
||||||
16
tests/hurl/notes/get_one.hurl
Normal file
16
tests/hurl/notes/get_one.hurl
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# GET /notes/<path> 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$"
|
||||||
9
tests/hurl/notes/get_one_accept_markdown.hurl
Normal file
9
tests/hurl/notes/get_one_accept_markdown.hurl
Normal file
|
|
@ -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."
|
||||||
5
tests/hurl/notes/get_one_dotdot_400.hurl
Normal file
5
tests/hurl/notes/get_one_dotdot_400.hurl
Normal file
|
|
@ -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
|
||||||
9
tests/hurl/notes/get_one_etag_304.hurl
Normal file
9
tests/hurl/notes/get_one_etag_304.hurl
Normal file
|
|
@ -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
|
||||||
6
tests/hurl/notes/get_one_etag_mismatch.hurl
Normal file
6
tests/hurl/notes/get_one_etag_mismatch.hurl
Normal file
|
|
@ -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"
|
||||||
8
tests/hurl/notes/get_one_no_frontmatter.hurl
Normal file
8
tests/hurl/notes/get_one_no_frontmatter.hurl
Normal file
|
|
@ -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"
|
||||||
3
tests/hurl/notes/get_one_not_found.hurl
Normal file
3
tests/hurl/notes/get_one_not_found.hurl
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Missing path returns 404.
|
||||||
|
GET {{host}}/notes/does-not-exist.md
|
||||||
|
HTTP 404
|
||||||
6
tests/hurl/query/all.hurl
Normal file
6
tests/hurl/query/all.hurl
Normal file
|
|
@ -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"
|
||||||
7
tests/hurl/query/anw_limit.hurl
Normal file
7
tests/hurl/query/anw_limit.hurl
Normal file
|
|
@ -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
|
||||||
8
tests/hurl/query/anw_path_segment_boundary.hurl
Normal file
8
tests/hurl/query/anw_path_segment_boundary.hurl
Normal file
|
|
@ -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"
|
||||||
10
tests/hurl/query/anw_recursive_false.hurl
Normal file
10
tests/hurl/query/anw_recursive_false.hurl
Normal file
|
|
@ -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"
|
||||||
3
tests/hurl/query/bad_exists_400.hurl
Normal file
3
tests/hurl/query/bad_exists_400.hurl
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# __exists value that isn't a bool is rejected with 400.
|
||||||
|
GET {{host}}/query?deprecated__exists=maybe
|
||||||
|
HTTP 400
|
||||||
3
tests/hurl/query/bad_regex_400.hurl
Normal file
3
tests/hurl/query/bad_regex_400.hurl
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Malformed regex value is rejected with 400 at parse time.
|
||||||
|
GET {{host}}/query?title__regex=(unclosed
|
||||||
|
HTTP 400
|
||||||
6
tests/hurl/query/comma_escape.hurl
Normal file
6
tests/hurl/query/comma_escape.hurl
Normal file
|
|
@ -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
|
||||||
8
tests/hurl/query/eq_bare_unifies_scalar_and_list.hurl
Normal file
8
tests/hurl/query/eq_bare_unifies_scalar_and_list.hurl
Normal file
|
|
@ -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"
|
||||||
10
tests/hurl/query/eq_scalar.hurl
Normal file
10
tests/hurl/query/eq_scalar.hurl
Normal file
|
|
@ -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
|
||||||
7
tests/hurl/query/exists_false.hurl
Normal file
7
tests/hurl/query/exists_false.hurl
Normal file
|
|
@ -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"
|
||||||
7
tests/hurl/query/exists_true.hurl
Normal file
7
tests/hurl/query/exists_true.hurl
Normal file
|
|
@ -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"
|
||||||
7
tests/hurl/query/in.hurl
Normal file
7
tests/hurl/query/in.hurl
Normal file
|
|
@ -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"
|
||||||
8
tests/hurl/query/nested_dotted_key.hurl
Normal file
8
tests/hurl/query/nested_dotted_key.hurl
Normal file
|
|
@ -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"
|
||||||
6
tests/hurl/query/not.hurl
Normal file
6
tests/hurl/query/not.hurl
Normal file
|
|
@ -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"
|
||||||
6
tests/hurl/query/prefix.hurl
Normal file
6
tests/hurl/query/prefix.hurl
Normal file
|
|
@ -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"
|
||||||
6
tests/hurl/query/range_dates.hurl
Normal file
6
tests/hurl/query/range_dates.hurl
Normal file
|
|
@ -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"
|
||||||
7
tests/hurl/query/range_datetime.hurl
Normal file
7
tests/hurl/query/range_datetime.hurl
Normal file
|
|
@ -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"
|
||||||
7
tests/hurl/query/regex.hurl
Normal file
7
tests/hurl/query/regex.hurl
Normal file
|
|
@ -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"
|
||||||
3
tests/hurl/query/unknown_control_param_400.hurl
Normal file
3
tests/hurl/query/unknown_control_param_400.hurl
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Unknown __anw- control parameter is rejected with 400.
|
||||||
|
GET {{host}}/query?__anw-bogus=1
|
||||||
|
HTTP 400
|
||||||
3
tests/hurl/query/unknown_operator_400.hurl
Normal file
3
tests/hurl/query/unknown_operator_400.hurl
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Unknown operator suffix is rejected with 400 (not silently ignored).
|
||||||
|
GET {{host}}/query?title__contians=foo
|
||||||
|
HTTP 400
|
||||||
69
tests/run-hurl.sh
Executable file
69
tests/run-hurl.sh
Executable file
|
|
@ -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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue