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