ANW-20 hurl HTTP contract-test harness

This commit is contained in:
Andreas Brenner 2026-05-14 19:04:03 +02:00
parent ec650e6c66
commit b4a33abd71
50 changed files with 415 additions and 2 deletions

View 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

View 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"

View file

@ -0,0 +1,3 @@
# Missing folder returns 404.
GET {{host}}/notes/no-such-folder/
HTTP 404

View 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"

View 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$"

View 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."

View 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

View 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

View 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"

View 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"

View file

@ -0,0 +1,3 @@
# Missing path returns 404.
GET {{host}}/notes/does-not-exist.md
HTTP 404

View 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"

View 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

View 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"

View 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"

View file

@ -0,0 +1,3 @@
# __exists value that isn't a bool is rejected with 400.
GET {{host}}/query?deprecated__exists=maybe
HTTP 400

View file

@ -0,0 +1,3 @@
# Malformed regex value is rejected with 400 at parse time.
GET {{host}}/query?title__regex=(unclosed
HTTP 400

View 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

View 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"

View 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

View 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"

View 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
View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View file

@ -0,0 +1,3 @@
# Unknown __anw- control parameter is rejected with 400.
GET {{host}}/query?__anw-bogus=1
HTTP 400

View file

@ -0,0 +1,3 @@
# Unknown operator suffix is rejected with 400 (not silently ignored).
GET {{host}}/query?title__contians=foo
HTTP 400