diff --git a/.env.example b/.env.example
index 0d8b13e..d64d3c5 100644
--- a/.env.example
+++ b/.env.example
@@ -6,3 +6,4 @@ export POSTGRES_PORT=5455
export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=postgres
export POSTGRES_DB=gc_index_relay_dev
+export REQUIRE_DB=true
diff --git a/AGENTS.md b/AGENTS.md
index 10e3350..a5c8a2b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,14 +4,14 @@ This is a web application written using the Phoenix web framework.
- **Always** run unit tests with `mix test.unit` and autoformat with `mix format` after making edits
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
-- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
+- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
## Commands
- `mix test.unit` — Run unit tests (no database required). **Always run after edits.**
- `mix test.integration` — Run integration tests (requires database).
- `mix format` — Apply autoformatting. **Always run after edits.**
-- `mix precommit` — Full pre-commit check: compile with warnings-as-errors, unlock unused deps, format, and run integration tests.
+- `mix precommit` — Full pre-commit check: compile with warnings-as-errors, unlock unused deps, format, credo, and run unit tests.
- `mix test test/path/to/file_test.exs` — Run a single test file.
- `mix test --failed` — Re-run previously failed tests.
diff --git a/README.md b/README.md
index 35d48a6..4f0e7ce 100644
--- a/README.md
+++ b/README.md
@@ -126,7 +126,7 @@ The test scenarios are documented in [test/features/relay_api.feature](test/feat
- CORS preflight and headers
- NIP-11 relay info document including the `icon` field
-Pre-commit check (compile with warnings-as-errors, format, credo, integration tests):
+Pre-commit check (compile with warnings-as-errors, format, credo, unit tests):
```bash
source .env
diff --git a/compose.yaml b/compose.yaml
index bc04026..fa5407d 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,7 +1,7 @@
services:
postgres:
image: docker.io/apache/age:release_PG17_1.6.0
- container_name: postgress_01
+ container_name: postgres_01
restart: unless-stopped
user: 1000:1000 # Should match host user
ports:
diff --git a/config/test.exs b/config/test.exs
index 8c5450e..fd4c917 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -11,8 +11,8 @@ config :gc_index_relay, GcIndexRelay.Repo,
username: System.get_env("POSTGRES_USER"),
password: System.get_env("POSTGRES_PASSWORD"),
database: "#{System.get_env("POSTGRES_DB")}#{System.get_env("MIX_TEST_PARTITION")}",
- hostname: "localhost",
- port: 5432,
+ hostname: System.get_env("POSTGRES_HOST", "localhost"),
+ port: String.to_integer(System.get_env("POSTGRES_PORT", "5455")),
show_sensitive_data_on_connection_error: true,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
diff --git a/lib/gc_index_relay.ex b/lib/gc_index_relay.ex
index 97dd6a8..5aa316b 100644
--- a/lib/gc_index_relay.ex
+++ b/lib/gc_index_relay.ex
@@ -1,9 +1,8 @@
defmodule GcIndexRelay do
@moduledoc """
- GcIndexRelay keeps the contexts that define your domain
- and business logic.
+ Mercury Index-Relay — a Nostr relay by GitCitadel.
- Contexts are also responsible for managing your data, regardless
- if it comes from the database, an external API or others.
+ Organises the application's domain contexts: event storage, validation,
+ filtering, and querying via `GcIndexRelay.Nostr`.
"""
end
diff --git a/lib/gc_index_relay_web/controllers/api_controller.ex b/lib/gc_index_relay_web/controllers/api_controller.ex
index ff42b9c..4782397 100644
--- a/lib/gc_index_relay_web/controllers/api_controller.ex
+++ b/lib/gc_index_relay_web/controllers/api_controller.ex
@@ -3,7 +3,7 @@ defmodule GcIndexRelayWeb.ApiController do
def index(conn, _params) do
json(conn, %{
- relay: "gc_index_relay",
+ relay: "Mercury Index-Relay",
version: Application.spec(:gc_index_relay, :vsn) |> to_string(),
endpoints: [
%{
diff --git a/lib/gc_index_relay_web/controllers/event_html.ex b/lib/gc_index_relay_web/controllers/event_html.ex
index 7031430..e69de29 100644
--- a/lib/gc_index_relay_web/controllers/event_html.ex
+++ b/lib/gc_index_relay_web/controllers/event_html.ex
@@ -1,5 +0,0 @@
-defmodule GcIndexRelayWeb.EventHTML do
- use GcIndexRelayWeb, :html
-
- embed_templates "event_html/*"
-end
diff --git a/lib/gc_index_relay_web/controllers/event_html/new.html.heex b/lib/gc_index_relay_web/controllers/event_html/new.html.heex
deleted file mode 100644
index f10e5d5..0000000
--- a/lib/gc_index_relay_web/controllers/event_html/new.html.heex
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- New Event
- <%!-- TODO: Display form for event details --%>
-
-
diff --git a/lib/gc_index_relay_web/controllers/filter_controller.ex b/lib/gc_index_relay_web/controllers/filter_controller.ex
index 781717a..979b757 100644
--- a/lib/gc_index_relay_web/controllers/filter_controller.ex
+++ b/lib/gc_index_relay_web/controllers/filter_controller.ex
@@ -56,7 +56,7 @@ defmodule GcIndexRelayWeb.FilterController do
""")
tag("Events")
- operation_id("query_events")
+ operation_id("filter_events")
response(200, "OK", Schema.ref(:PubEventList))
response(400, "Bad Request")
end
diff --git a/lib/gc_index_relay_web/controllers/page_html/home.html.heex b/lib/gc_index_relay_web/controllers/page_html/home.html.heex
index 6b36f7f..368818c 100644
--- a/lib/gc_index_relay_web/controllers/page_html/home.html.heex
+++ b/lib/gc_index_relay_web/controllers/page_html/home.html.heex
@@ -9,10 +9,10 @@
Mercury Index-Relay
-
+
by GitCitadel · v{Application.spec(:gc_index_relay, :vsn)}
-
+
A Nostr index relay for the HTTP protocol. Specialises in swift retrieval
of publications, repos, and graphs of related events.
@@ -34,19 +34,19 @@
REST API
-
+
Full event lifecycle over HTTP — publish, query, fetch by ID, delete.
NIP-01 Filters
-
+
Filter by kind, author, tags, and time window. Supports #p, #e, and more.
NIP-11 & NIP-70
-
+
Relay info at GET / with Accept: application/nostr+json.
Protected events are rejected.
@@ -54,12 +54,12 @@
-
+
Endpoints
<%= for {method, path, desc} <- [
{"GET", "/api", "List available endpoints"},
@@ -71,17 +71,53 @@
{"GET", "/api/swagger", "Interactive Swagger UI"}
] do %>
-
{method} {path}
-
{desc}
+
{method} {path}
+
{desc}
<% end %>
-
diff --git a/mix.exs b/mix.exs
index 0ccf73a..75ed626 100644
--- a/mix.exs
+++ b/mix.exs
@@ -82,7 +82,7 @@ defmodule GcIndexRelay.MixProject do
"test.integration": [
"ecto.create --quiet",
"ecto.migrate --quiet",
- "test --only integration"
+ "test --only integration --trace"
],
precommit: [
"compile --warnings-as-errors",
diff --git a/priv/static/images/gitcitadel_icon.png b/priv/static/images/gitcitadel_icon.png
new file mode 100644
index 0000000..d7ea93d
Binary files /dev/null and b/priv/static/images/gitcitadel_icon.png differ
diff --git a/priv/static/swagger.json b/priv/static/swagger.json
index d2a054c..1117b3c 100644
--- a/priv/static/swagger.json
+++ b/priv/static/swagger.json
@@ -149,7 +149,7 @@
"/api/events/filter": {
"post": {
"description": " Returns a list of events matching the filter in descending order of created_at time.\n Response is returned as a batch, not streamed, so a `limit` parameter is required to prevent\n the response from getting too large.\n",
- "operationId": "query_events",
+ "operationId": "filter_events",
"parameters": [],
"responses": {
"200": {
diff --git a/test/gc_index_relay/nostr_test.exs b/test/gc_index_relay/nostr_test.exs
index 3c566dc..665bfb1 100644
--- a/test/gc_index_relay/nostr_test.exs
+++ b/test/gc_index_relay/nostr_test.exs
@@ -1,11 +1,5 @@
defmodule GcIndexRelay.NostrTest do
use GcIndexRelay.DataCase
- describe "events" do
- # Tests to be added
- end
-
- describe "tags" do
- # Tests to be added
- end
+ @moduletag :integration
end
diff --git a/test/gc_index_relay_web/controllers/page_controller_test.exs b/test/gc_index_relay_web/controllers/page_controller_test.exs
index 23da8a0..8ac1c45 100644
--- a/test/gc_index_relay_web/controllers/page_controller_test.exs
+++ b/test/gc_index_relay_web/controllers/page_controller_test.exs
@@ -1,8 +1,10 @@
defmodule GcIndexRelayWeb.PageControllerTest do
use GcIndexRelayWeb.ConnCase
- test "GET /", %{conn: conn} do
+ @moduletag :unit
+
+ test "GET / renders Mercury landing page", %{conn: conn} do
conn = get(conn, ~p"/")
- assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
+ assert html_response(conn, 200) =~ "Mercury Index-Relay"
end
end
diff --git a/test/gc_index_relay_web/relay_integration_test.exs b/test/gc_index_relay_web/relay_integration_test.exs
index c1e83e2..1d68be6 100644
--- a/test/gc_index_relay_web/relay_integration_test.exs
+++ b/test/gc_index_relay_web/relay_integration_test.exs
@@ -1,10 +1,10 @@
defmodule GcIndexRelayWeb.RelayIntegrationTest do
@moduledoc """
- Integration probe of the relay REST API, covering the scenarios a browser-based
- Nostr client (e.g. Jumble at https://jumble.imwald.eu/) would exercise.
+ Integration tests for relay REST API scenarios that require a live database.
+ Covers event publishing, deletion, and filter-based querying.
Scenarios are mapped 1:1 to test/features/relay_api.feature.
- Run with: mix test.integration test/gc_index_relay_web/relay_integration_test.exs
+ Run with: source .env && mix test.integration test/gc_index_relay_web/relay_integration_test.exs
"""
use GcIndexRelayWeb.ConnCase
@@ -22,37 +22,16 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
{:ok, conn: conn}
end
- # ---------------------------------------------------------------------------
- # Discovery
- # ---------------------------------------------------------------------------
-
- describe "GET /api — discovery" do
- test "client discovers available endpoints", %{conn: conn} do
- # When
- conn = get(conn, ~p"/api")
-
- # Then
- assert %{"endpoints" => endpoints} = json_response(conn, 200)
- paths = Enum.map(endpoints, & &1["path"])
- assert "/api/events" in paths
- assert "/api/events/:id" in paths
- assert "/api/events/filter" in paths or "/api/events/:id" in paths
- end
- end
-
# ---------------------------------------------------------------------------
# Publishing events (POST /api/events)
# ---------------------------------------------------------------------------
describe "POST /api/events — publishing" do
test "client publishes a valid kind 1 note", %{conn: conn} do
- # Given
event = valid_pub_event_fixture(%{kind: 1, content: "hello nostr"})
- # When
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
- # Then
assert %{"data" => data} = json_response(conn, 201)
assert data["id"] == event.id
assert data["kind"] == 1
@@ -60,54 +39,24 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
end
test "client publishes a kind 0 profile event with metadata content", %{conn: conn} do
- # Given — kind 0 content is a JSON string per NIP-01
metadata = Jason.encode!(%{name: "testuser", about: "a test profile", picture: ""})
event = valid_pub_event_fixture(%{kind: 0, content: metadata})
- # When
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
- # Then — event was stored and returned
assert %{"data" => data} = json_response(conn, 201)
assert data["kind"] == 0
assert data["content"] == metadata
end
test "relay rejects a duplicate event with 409", %{conn: conn} do
- # Given
event = valid_pub_event_fixture()
post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
- # When — same event again
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
- # Then
assert json_response(conn, 409)
end
-
- test "relay rejects an event with a tampered ID with 400", %{conn: conn} do
- # Given — ID does not match hash of content
- event = invalid_id_pub_event_fixture()
-
- # When
- conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
-
- # Then
- assert %{"errors" => %{"detail" => detail}} = json_response(conn, 400)
- assert detail =~ "invalid"
- end
-
- test "relay rejects a NIP-70 protected event with 400", %{conn: conn} do
- # Given — event has the ["-"] protection tag
- event = valid_pub_event_fixture(%{tags: [["-"]]})
-
- # When
- conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
-
- # Then
- assert %{"errors" => %{"detail" => detail}} = json_response(conn, 400)
- assert detail =~ "auth-required"
- end
end
# ---------------------------------------------------------------------------
@@ -116,24 +65,19 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
describe "DELETE /api/events/:id — deletion" do
test "client deletes an existing event and gets 204", %{conn: conn} do
- # Given
event = valid_pub_event_fixture()
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event)
hex_id = Base.encode16(db_event.id, case: :lower)
- # When
conn = delete(conn, ~p"/api/events/#{hex_id}")
- # Then
assert conn.status == 204
assert conn.resp_body == ""
end
test "deleting a non-existent event returns 404", %{conn: conn} do
- # When
conn = delete(conn, ~p"/api/events/#{String.duplicate("b", 64)}")
- # Then
assert json_response(conn, 404)
end
end
@@ -144,29 +88,18 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
describe "GET /api/events — cacheable query" do
test "client fetches events with since/until/limit query params", %{conn: conn} do
- # Given
- _e1 =
- valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
- |> then(&GcIndexRelay.Nostr.create_event/1)
+ valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
+ |> then(&GcIndexRelay.Nostr.create_event/1)
- _e2 =
- valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2})
- |> then(&GcIndexRelay.Nostr.create_event/1)
+ valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2})
+ |> then(&GcIndexRelay.Nostr.create_event/1)
- # When
conn = get(conn, "/api/events?since=0&until=9999999999&limit=10")
- # Then
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 2
- end
-
- test "GET /api/events without required params is rejected with 400", %{conn: conn} do
- # When — missing since, until, limit
- conn = get(conn, "/api/events?kinds=1")
-
- # Then
- assert json_response(conn, 400)
+ [first, second] = events
+ assert first["created_at"] >= second["created_at"]
end
end
@@ -176,19 +109,14 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
describe "POST /api/events/filter — querying" do
test "client fetches recent kind 1 notes, newest first", %{conn: conn} do
- # Given
- _older =
- valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
- |> then(&GcIndexRelay.Nostr.create_event/1)
+ valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
+ |> then(&GcIndexRelay.Nostr.create_event/1)
- _newer =
- valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2})
- |> then(&GcIndexRelay.Nostr.create_event/1)
+ valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2})
+ |> then(&GcIndexRelay.Nostr.create_event/1)
- # When
conn = post(conn, ~p"/api/events/filter", %{"kinds" => [1], "limit" => 10})
- # Then
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 2
[first, second] = events
@@ -196,13 +124,11 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
end
test "client fetches a user profile (kind 0) by author", %{conn: conn} do
- # Given
%{keypair1: kp} = test_keypairs()
metadata = Jason.encode!(%{name: "alice"})
event = valid_pub_event_fixture(%{kind: 0, content: metadata})
{:ok, _} = GcIndexRelay.Nostr.create_event(event)
- # When
conn =
post(conn, ~p"/api/events/filter", %{
"kinds" => [0],
@@ -210,69 +136,43 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
"limit" => 1
})
- # Then
assert %{"data" => [profile]} = json_response(conn, 200)
assert profile["kind"] == 0
assert profile["pubkey"] == kp.public_key_hex
end
test "client fetches events mentioning a pubkey via #p tag", %{conn: conn} do
- # Given — an event tagging another user
mentioned_pubkey = String.duplicate("ab", 32)
event = valid_pub_event_fixture(%{tags: [["p", mentioned_pubkey]]})
{:ok, _} = GcIndexRelay.Nostr.create_event(event)
- _unrelated =
- valid_pub_event_fixture(%{created_at: 1_640_000_001, keypair: :keypair2})
- |> then(&GcIndexRelay.Nostr.create_event/1)
+ valid_pub_event_fixture(%{created_at: 1_640_000_001, keypair: :keypair2})
+ |> then(&GcIndexRelay.Nostr.create_event/1)
- # When
conn = post(conn, ~p"/api/events/filter", %{"#p" => [mentioned_pubkey], "limit" => 10})
- # Then — only the tagged event is returned
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 1
assert hd(events)["id"] == event.id
end
test "client fetches events within a time window", %{conn: conn} do
- # Given
- _old =
- valid_pub_event_fixture(%{kind: 1, created_at: 1_500_000_000})
- |> then(&GcIndexRelay.Nostr.create_event/1)
+ valid_pub_event_fixture(%{kind: 1, created_at: 1_500_000_000})
+ |> then(&GcIndexRelay.Nostr.create_event/1)
- _new =
- valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001, keypair: :keypair2})
- |> then(&GcIndexRelay.Nostr.create_event/1)
+ valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001, keypair: :keypair2})
+ |> then(&GcIndexRelay.Nostr.create_event/1)
- # When — only events after 1_600_000_000
conn =
post(conn, ~p"/api/events/filter", %{
"since" => 1_600_000_000,
"limit" => 10
})
- # Then
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 1
assert hd(events)["created_at"] == 1_640_000_001
end
-
- test "filter without a limit is rejected with 400", %{conn: conn} do
- # When
- conn = post(conn, ~p"/api/events/filter", %{"kinds" => [1]})
-
- # Then
- assert json_response(conn, 400)
- end
-
- test "filter with limit over 100 is rejected with 400", %{conn: conn} do
- # When
- conn = post(conn, ~p"/api/events/filter", %{"limit" => 101})
-
- # Then
- assert json_response(conn, 400)
- end
end
# ---------------------------------------------------------------------------
@@ -281,160 +181,21 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
describe "GET /api/events/:id — single event lookup" do
test "client fetches a specific event by ID", %{conn: conn} do
- # Given
event = valid_pub_event_fixture()
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event)
hex_id = Base.encode16(db_event.id, case: :lower)
- # When
conn = get(conn, ~p"/api/events/#{hex_id}")
- # Then
assert %{"data" => data} = json_response(conn, 200)
assert data["id"] == hex_id
assert data["content"] == event.content
end
test "fetching a non-existent event returns 404", %{conn: conn} do
- # When
conn = get(conn, ~p"/api/events/#{String.duplicate("a", 64)}")
- # Then
assert json_response(conn, 404)
end
end
-
- # ---------------------------------------------------------------------------
- # CORS — required for browser-based clients like Jumble
- # ---------------------------------------------------------------------------
-
- describe "CORS headers — browser client compatibility" do
- test "relay includes CORS headers on a GET response", %{conn: conn} do
- # When
- conn = get(conn, ~p"/api")
-
- # Then
- assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
- methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("")
- assert methods =~ "GET"
- assert methods =~ "POST"
- assert methods =~ "DELETE"
- end
-
- test "relay includes CORS headers on a POST response", %{conn: conn} do
- # When
- conn = post(conn, ~p"/api/events/filter", %{"limit" => 10})
-
- # Then
- assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
- end
-
- test "relay responds 200 to a browser preflight OPTIONS request", %{conn: conn} do
- # When — browser sends preflight before the real request
- conn = options(conn, "/api/events")
-
- # Then
- assert conn.status == 200
- assert conn.resp_body == ""
- assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
- methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("")
- assert methods =~ "OPTIONS"
- end
- end
-
- # ---------------------------------------------------------------------------
- # NIP-11 relay information document
- # ---------------------------------------------------------------------------
-
- describe "GET / with Accept: application/nostr+json — NIP-11" do
- test "returns 200 with application/nostr+json content-type", %{conn: conn} do
- # When
- conn =
- conn
- |> put_req_header("accept", "application/nostr+json")
- |> get("/")
-
- # Then
- assert conn.status == 200
- [content_type | _] = get_resp_header(conn, "content-type")
- assert content_type =~ "application/nostr+json"
- end
-
- test "response body is valid JSON with required NIP-11 fields", %{conn: conn} do
- # When
- conn =
- conn
- |> put_req_header("accept", "application/nostr+json")
- |> get("/")
-
- # Then
- assert {:ok, body} = Jason.decode(conn.resp_body)
- assert is_binary(body["name"])
- assert is_list(body["supported_nips"])
- assert is_map(body["limitation"])
- assert is_binary(body["icon"]) and String.starts_with?(body["icon"], "http")
- end
-
- test "supported_nips list includes NIP-11 and NIP-70", %{conn: conn} do
- # When
- conn =
- conn
- |> put_req_header("accept", "application/nostr+json")
- |> get("/")
-
- # Then
- assert {:ok, %{"supported_nips" => nips}} = Jason.decode(conn.resp_body)
- assert 11 in nips
- assert 70 in nips
- end
-
- test "limitation object contains expected fields", %{conn: conn} do
- # When
- conn =
- conn
- |> put_req_header("accept", "application/nostr+json")
- |> get("/")
-
- # Then
- assert {:ok, %{"limitation" => limitation}} = Jason.decode(conn.resp_body)
- assert Map.has_key?(limitation, "max_limit")
- assert Map.has_key?(limitation, "auth_required")
- assert Map.has_key?(limitation, "payment_required")
- end
-
- test "NIP-11 response includes CORS headers", %{conn: conn} do
- # When
- conn =
- conn
- |> put_req_header("accept", "application/nostr+json")
- |> get("/")
-
- # Then
- assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
- end
-
- test "regular browser request to GET / still returns HTML", %{conn: conn} do
- # When — no special Accept header, browser default
- conn =
- conn
- |> put_req_header("accept", "text/html,application/xhtml+xml")
- |> get("/")
-
- # Then
- assert conn.status == 200
- [content_type | _] = get_resp_header(conn, "content-type")
- assert content_type =~ "text/html"
- end
- end
-
- # ---------------------------------------------------------------------------
- # Health check
- # ---------------------------------------------------------------------------
-
- describe "GET /health — health check" do
- test "health check returns 200", %{conn: conn} do
- conn = get(conn, "/health")
- assert conn.status == 200
- end
- end
end
diff --git a/test/gc_index_relay_web/relay_unit_test.exs b/test/gc_index_relay_web/relay_unit_test.exs
new file mode 100644
index 0000000..4eeb9c1
--- /dev/null
+++ b/test/gc_index_relay_web/relay_unit_test.exs
@@ -0,0 +1,173 @@
+defmodule GcIndexRelayWeb.RelayUnitTest do
+ @moduledoc """
+ Unit tests for relay HTTP endpoints that require no database access.
+ Tests controller/plug behaviour: routing, validation rejection, CORS headers,
+ NIP-11 relay info, and health check.
+
+ Scenarios are a subset of test/features/relay_api.feature.
+ Run with: mix test.unit test/gc_index_relay_web/relay_unit_test.exs
+ """
+
+ use GcIndexRelayWeb.ConnCase, async: true
+
+ import GcIndexRelay.NostrFixtures
+
+ @moduletag :unit
+
+ setup %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "application/json")
+ |> put_req_header("content-type", "application/json")
+
+ {:ok, conn: conn}
+ end
+
+ # ---------------------------------------------------------------------------
+ # Discovery
+ # ---------------------------------------------------------------------------
+
+ describe "GET /api — discovery" do
+ test "client discovers available endpoints", %{conn: conn} do
+ conn = get(conn, ~p"/api")
+
+ assert %{"endpoints" => endpoints} = json_response(conn, 200)
+ paths = Enum.map(endpoints, & &1["path"])
+ assert "/api/events" in paths
+ assert "/api/events/:id" in paths
+ assert "/api/events/filter" in paths
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Request validation — rejected before any DB access
+ # ---------------------------------------------------------------------------
+
+ describe "POST /api/events — validation rejections" do
+ test "relay rejects a NIP-70 protected event with 400", %{conn: conn} do
+ event = valid_pub_event_fixture(%{tags: [["-"]]})
+
+ conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
+
+ assert %{"errors" => %{"detail" => detail}} = json_response(conn, 400)
+ assert detail =~ "auth-required"
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # CORS — required for browser-based clients like Jumble
+ # ---------------------------------------------------------------------------
+
+ describe "CORS headers — browser client compatibility" do
+ test "relay includes CORS headers on a GET response", %{conn: conn} do
+ conn = get(conn, ~p"/api")
+
+ assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
+ methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("")
+ assert methods =~ "GET"
+ assert methods =~ "POST"
+ assert methods =~ "DELETE"
+ assert methods =~ "OPTIONS"
+ end
+
+ test "relay includes CORS headers on a POST response", %{conn: conn} do
+ conn = post(conn, ~p"/api/events/filter", %{"limit" => 10})
+
+ assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
+ end
+
+ test "relay responds 200 to a browser preflight OPTIONS request", %{conn: conn} do
+ conn = options(conn, "/api/events")
+
+ assert conn.status == 200
+ assert conn.resp_body == ""
+ assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
+ methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("")
+ assert methods =~ "OPTIONS"
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # NIP-11 relay information document
+ # ---------------------------------------------------------------------------
+
+ describe "GET / with Accept: application/nostr+json — NIP-11" do
+ test "returns 200 with application/nostr+json content-type", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "application/nostr+json")
+ |> get("/")
+
+ assert conn.status == 200
+ [content_type | _] = get_resp_header(conn, "content-type")
+ assert content_type =~ "application/nostr+json"
+ end
+
+ test "response body is valid JSON with required NIP-11 fields", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "application/nostr+json")
+ |> get("/")
+
+ assert {:ok, body} = Jason.decode(conn.resp_body)
+ assert is_binary(body["name"])
+ assert is_list(body["supported_nips"])
+ assert is_map(body["limitation"])
+ assert is_binary(body["icon"]) and String.starts_with?(body["icon"], "http")
+ end
+
+ test "supported_nips list includes NIP-11 and NIP-70", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "application/nostr+json")
+ |> get("/")
+
+ assert {:ok, %{"supported_nips" => nips}} = Jason.decode(conn.resp_body)
+ assert 11 in nips
+ assert 70 in nips
+ end
+
+ test "limitation object contains expected fields", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "application/nostr+json")
+ |> get("/")
+
+ assert {:ok, %{"limitation" => limitation}} = Jason.decode(conn.resp_body)
+ assert Map.has_key?(limitation, "max_limit")
+ assert Map.has_key?(limitation, "auth_required")
+ assert Map.has_key?(limitation, "payment_required")
+ end
+
+ test "NIP-11 response includes CORS headers", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "application/nostr+json")
+ |> get("/")
+
+ assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
+ end
+
+ test "regular browser request to GET / still returns HTML", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "text/html,application/xhtml+xml")
+ |> get("/")
+
+ assert conn.status == 200
+ [content_type | _] = get_resp_header(conn, "content-type")
+ assert content_type =~ "text/html"
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Health check
+ # ---------------------------------------------------------------------------
+
+ describe "GET /health — health check" do
+ test "health check returns 200", %{conn: conn} do
+ conn = get(conn, "/health")
+ assert conn.status == 200
+ end
+ end
+end