Browse Source

more bug-fixes

correct and expand the tests
test/local-setup
Silberengel 3 weeks ago
parent
commit
6f073ea766
  1. 1
      .env.example
  2. 4
      AGENTS.md
  3. 2
      README.md
  4. 2
      compose.yaml
  5. 4
      config/test.exs
  6. 7
      lib/gc_index_relay.ex
  7. 2
      lib/gc_index_relay_web/controllers/api_controller.ex
  8. 5
      lib/gc_index_relay_web/controllers/event_html.ex
  9. 6
      lib/gc_index_relay_web/controllers/event_html/new.html.heex
  10. 2
      lib/gc_index_relay_web/controllers/filter_controller.ex
  11. 62
      lib/gc_index_relay_web/controllers/page_html/home.html.heex
  12. 2
      mix.exs
  13. BIN
      priv/static/images/gitcitadel_icon.png
  14. 2
      priv/static/swagger.json
  15. 8
      test/gc_index_relay/nostr_test.exs
  16. 6
      test/gc_index_relay_web/controllers/page_controller_test.exs
  17. 277
      test/gc_index_relay_web/relay_integration_test.exs
  18. 173
      test/gc_index_relay_web/relay_unit_test.exs

1
.env.example

@ -6,3 +6,4 @@ export POSTGRES_PORT=5455
export POSTGRES_USER=postgres export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=postgres export POSTGRES_PASSWORD=postgres
export POSTGRES_DB=gc_index_relay_dev export POSTGRES_DB=gc_index_relay_dev
export REQUIRE_DB=true

4
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 - **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 `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 ## Commands
- `mix test.unit` — Run unit tests (no database required). **Always run after edits.** - `mix test.unit` — Run unit tests (no database required). **Always run after edits.**
- `mix test.integration` — Run integration tests (requires database). - `mix test.integration` — Run integration tests (requires database).
- `mix format` — Apply autoformatting. **Always run after edits.** - `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 test/path/to/file_test.exs` — Run a single test file.
- `mix test --failed` — Re-run previously failed tests. - `mix test --failed` — Re-run previously failed tests.

2
README.md

@ -126,7 +126,7 @@ The test scenarios are documented in [test/features/relay_api.feature](test/feat
- CORS preflight and headers - CORS preflight and headers
- NIP-11 relay info document including the `icon` field - 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 ```bash
source .env source .env

2
compose.yaml

@ -1,7 +1,7 @@
services: services:
postgres: postgres:
image: docker.io/apache/age:release_PG17_1.6.0 image: docker.io/apache/age:release_PG17_1.6.0
container_name: postgress_01 container_name: postgres_01
restart: unless-stopped restart: unless-stopped
user: 1000:1000 # Should match host user user: 1000:1000 # Should match host user
ports: ports:

4
config/test.exs

@ -11,8 +11,8 @@ config :gc_index_relay, GcIndexRelay.Repo,
username: System.get_env("POSTGRES_USER"), username: System.get_env("POSTGRES_USER"),
password: System.get_env("POSTGRES_PASSWORD"), password: System.get_env("POSTGRES_PASSWORD"),
database: "#{System.get_env("POSTGRES_DB")}#{System.get_env("MIX_TEST_PARTITION")}", database: "#{System.get_env("POSTGRES_DB")}#{System.get_env("MIX_TEST_PARTITION")}",
hostname: "localhost", hostname: System.get_env("POSTGRES_HOST", "localhost"),
port: 5432, port: String.to_integer(System.get_env("POSTGRES_PORT", "5455")),
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2 pool_size: System.schedulers_online() * 2

7
lib/gc_index_relay.ex

@ -1,9 +1,8 @@
defmodule GcIndexRelay do defmodule GcIndexRelay do
@moduledoc """ @moduledoc """
GcIndexRelay keeps the contexts that define your domain Mercury Index-Relay a Nostr relay by GitCitadel.
and business logic.
Contexts are also responsible for managing your data, regardless Organises the application's domain contexts: event storage, validation,
if it comes from the database, an external API or others. filtering, and querying via `GcIndexRelay.Nostr`.
""" """
end end

2
lib/gc_index_relay_web/controllers/api_controller.ex

@ -3,7 +3,7 @@ defmodule GcIndexRelayWeb.ApiController do
def index(conn, _params) do def index(conn, _params) do
json(conn, %{ json(conn, %{
relay: "gc_index_relay", relay: "Mercury Index-Relay",
version: Application.spec(:gc_index_relay, :vsn) |> to_string(), version: Application.spec(:gc_index_relay, :vsn) |> to_string(),
endpoints: [ endpoints: [
%{ %{

5
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

6
lib/gc_index_relay_web/controllers/event_html/new.html.heex

@ -1,6 +0,0 @@
<Layouts.app flash={@flash}>
<section>
<h2>New Event</h2>
<%!-- TODO: Display form for event details --%>
</section>
</Layouts.app>

2
lib/gc_index_relay_web/controllers/filter_controller.ex

@ -56,7 +56,7 @@ defmodule GcIndexRelayWeb.FilterController do
""") """)
tag("Events") tag("Events")
operation_id("query_events") operation_id("filter_events")
response(200, "OK", Schema.ref(:PubEventList)) response(200, "OK", Schema.ref(:PubEventList))
response(400, "Bad Request") response(400, "Bad Request")
end end

62
lib/gc_index_relay_web/controllers/page_html/home.html.heex

@ -9,10 +9,10 @@
<h1 style="font-size:1.5rem; font-weight:700; margin-bottom:0.25rem;"> <h1 style="font-size:1.5rem; font-weight:700; margin-bottom:0.25rem;">
Mercury Index-Relay Mercury Index-Relay
</h1> </h1>
<p style="font-size:0.8rem; opacity:0.5; margin-bottom:0.75rem;"> <p style="font-size:0.8rem; opacity:0.6; margin-bottom:0.75rem;">
by GitCitadel &nbsp;·&nbsp; v{Application.spec(:gc_index_relay, :vsn)} by GitCitadel &nbsp;·&nbsp; v{Application.spec(:gc_index_relay, :vsn)}
</p> </p>
<p style="font-size:0.875rem; opacity:0.7; line-height:1.5;"> <p style="font-size:0.9rem; line-height:1.6;">
A Nostr index relay for the HTTP protocol. Specialises in swift retrieval A Nostr index relay for the HTTP protocol. Specialises in swift retrieval
of publications, repos, and graphs of related events. of publications, repos, and graphs of related events.
</p> </p>
@ -34,19 +34,19 @@
<div style="margin-bottom:2rem; display:flex; flex-direction:column; gap:0.75rem;"> <div style="margin-bottom:2rem; display:flex; flex-direction:column; gap:0.75rem;">
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> <div class="rounded-box border border-base-300" style="padding:0.875rem;">
<strong style="font-size:0.875rem;">REST API</strong> <strong style="font-size:0.875rem;">REST API</strong>
<p style="font-size:0.75rem; opacity:0.6; margin-top:0.25rem;"> <p style="font-size:0.8rem; margin-top:0.25rem;">
Full event lifecycle over HTTP — publish, query, fetch by ID, delete. Full event lifecycle over HTTP — publish, query, fetch by ID, delete.
</p> </p>
</div> </div>
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> <div class="rounded-box border border-base-300" style="padding:0.875rem;">
<strong style="font-size:0.875rem;">NIP-01 Filters</strong> <strong style="font-size:0.875rem;">NIP-01 Filters</strong>
<p style="font-size:0.75rem; opacity:0.6; margin-top:0.25rem;"> <p style="font-size:0.8rem; margin-top:0.25rem;">
Filter by kind, author, tags, and time window. Supports #p, #e, and more. Filter by kind, author, tags, and time window. Supports #p, #e, and more.
</p> </p>
</div> </div>
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> <div class="rounded-box border border-base-300" style="padding:0.875rem;">
<strong style="font-size:0.875rem;">NIP-11 &amp; NIP-70</strong> <strong style="font-size:0.875rem;">NIP-11 &amp; NIP-70</strong>
<p style="font-size:0.75rem; opacity:0.6; margin-top:0.25rem;"> <p style="font-size:0.8rem; margin-top:0.25rem;">
Relay info at <code>GET /</code> with <code>Accept: application/nostr+json</code>. Relay info at <code>GET /</code> with <code>Accept: application/nostr+json</code>.
Protected events are rejected. Protected events are rejected.
</p> </p>
@ -54,12 +54,12 @@
</div> </div>
<div style="margin-bottom:2rem;"> <div style="margin-bottom:2rem;">
<p style="font-size:0.7rem; font-weight:600; opacity:0.4; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:0.5rem;"> <p style="font-size:0.7rem; font-weight:600; opacity:0.55; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:0.5rem;">
Endpoints Endpoints
</p> </p>
<div <div
class="rounded-box border border-base-300" class="rounded-box border border-base-300"
style="font-size:0.75rem; overflow:hidden;" style="font-size:0.8rem; overflow:hidden;"
> >
<%= for {method, path, desc} <- [ <%= for {method, path, desc} <- [
{"GET", "/api", "List available endpoints"}, {"GET", "/api", "List available endpoints"},
@ -71,17 +71,53 @@
{"GET", "/api/swagger", "Interactive Swagger UI"} {"GET", "/api/swagger", "Interactive Swagger UI"}
] do %> ] do %>
<div style="padding:0.5rem 0.875rem; border-bottom:1px solid var(--fallback-b3,oklch(var(--b3)/1));"> <div style="padding:0.5rem 0.875rem; border-bottom:1px solid var(--fallback-b3,oklch(var(--b3)/1));">
<p style="font-family:monospace; opacity:0.5; font-size:0.7rem;">{method} {path}</p> <p style="font-family:monospace; font-size:0.75rem; opacity:0.7;">{method} {path}</p>
<p style="opacity:0.6; margin-top:0.15rem;">{desc}</p> <p style="margin-top:0.15rem;">{desc}</p>
</div> </div>
<% end %> <% end %>
</div> </div>
</div> </div>
<footer style="text-align:center; font-size:0.75rem; opacity:0.35; padding-top:1rem; border-top:1px solid oklch(var(--b3)/1);"> <footer style="text-align:center; font-size:0.75rem; opacity:0.6; padding-top:1rem; border-top:1px solid oklch(var(--b3)/1);">
<a href="https://gitcitadel.com" class="hover:opacity-100">GitCitadel</a> <a
href="https://gitcitadel.com"
target="_blank"
rel="noopener noreferrer"
class="hover:opacity-100"
style="display:inline-flex; align-items:center; gap:0.3rem; vertical-align:middle;"
>
<img
src={~p"/images/gitcitadel_icon.png"}
alt=""
style="width:16px; height:16px;"
/>GitCitadel
</a>
&nbsp;·&nbsp;
<a
href="https://git.imwald.eu/silberengel/gc_index_relay"
target="_blank"
rel="noopener noreferrer"
class="hover:opacity-100"
>
Relay Repo
</a>
&nbsp;·&nbsp; &nbsp;·&nbsp;
<a href="https://git.imwald.eu/silberengel/gc_index_relay" class="hover:opacity-100">Relay Repo</a> <a
&nbsp;·&nbsp; <a href="https://github.com/ShadowySupercode" class="hover:opacity-100">GitCitadel on GitHub</a> href="https://github.com/ShadowySupercode"
target="_blank"
rel="noopener noreferrer"
class="hover:opacity-100"
>
GitCitadel on GitHub
</a>
&nbsp;·&nbsp;
<a
href="https://alexandria.gitcitadel.eu/events?q=GitCitadel%40gitcitadel.com"
target="_blank"
rel="noopener noreferrer"
class="hover:opacity-100"
>
GitCitadel on Nostr
</a>
</footer> </footer>
</div> </div>

2
mix.exs

@ -82,7 +82,7 @@ defmodule GcIndexRelay.MixProject do
"test.integration": [ "test.integration": [
"ecto.create --quiet", "ecto.create --quiet",
"ecto.migrate --quiet", "ecto.migrate --quiet",
"test --only integration" "test --only integration --trace"
], ],
precommit: [ precommit: [
"compile --warnings-as-errors", "compile --warnings-as-errors",

BIN
priv/static/images/gitcitadel_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

2
priv/static/swagger.json

@ -149,7 +149,7 @@
"/api/events/filter": { "/api/events/filter": {
"post": { "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", "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": [], "parameters": [],
"responses": { "responses": {
"200": { "200": {

8
test/gc_index_relay/nostr_test.exs

@ -1,11 +1,5 @@
defmodule GcIndexRelay.NostrTest do defmodule GcIndexRelay.NostrTest do
use GcIndexRelay.DataCase use GcIndexRelay.DataCase
describe "events" do @moduletag :integration
# Tests to be added
end
describe "tags" do
# Tests to be added
end
end end

6
test/gc_index_relay_web/controllers/page_controller_test.exs

@ -1,8 +1,10 @@
defmodule GcIndexRelayWeb.PageControllerTest do defmodule GcIndexRelayWeb.PageControllerTest do
use GcIndexRelayWeb.ConnCase use GcIndexRelayWeb.ConnCase
test "GET /", %{conn: conn} do @moduletag :unit
test "GET / renders Mercury landing page", %{conn: conn} do
conn = get(conn, ~p"/") 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
end end

277
test/gc_index_relay_web/relay_integration_test.exs

@ -1,10 +1,10 @@
defmodule GcIndexRelayWeb.RelayIntegrationTest do defmodule GcIndexRelayWeb.RelayIntegrationTest do
@moduledoc """ @moduledoc """
Integration probe of the relay REST API, covering the scenarios a browser-based Integration tests for relay REST API scenarios that require a live database.
Nostr client (e.g. Jumble at https://jumble.imwald.eu/) would exercise. Covers event publishing, deletion, and filter-based querying.
Scenarios are mapped 1:1 to test/features/relay_api.feature. 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 use GcIndexRelayWeb.ConnCase
@ -22,37 +22,16 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
{:ok, conn: conn} {:ok, conn: conn}
end 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) # Publishing events (POST /api/events)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
describe "POST /api/events — publishing" do describe "POST /api/events — publishing" do
test "client publishes a valid kind 1 note", %{conn: conn} do test "client publishes a valid kind 1 note", %{conn: conn} do
# Given
event = valid_pub_event_fixture(%{kind: 1, content: "hello nostr"}) event = valid_pub_event_fixture(%{kind: 1, content: "hello nostr"})
# When
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
# Then
assert %{"data" => data} = json_response(conn, 201) assert %{"data" => data} = json_response(conn, 201)
assert data["id"] == event.id assert data["id"] == event.id
assert data["kind"] == 1 assert data["kind"] == 1
@ -60,54 +39,24 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
end end
test "client publishes a kind 0 profile event with metadata content", %{conn: conn} do 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: ""}) metadata = Jason.encode!(%{name: "testuser", about: "a test profile", picture: ""})
event = valid_pub_event_fixture(%{kind: 0, content: metadata}) event = valid_pub_event_fixture(%{kind: 0, content: metadata})
# When
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) 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" => data} = json_response(conn, 201)
assert data["kind"] == 0 assert data["kind"] == 0
assert data["content"] == metadata assert data["content"] == metadata
end end
test "relay rejects a duplicate event with 409", %{conn: conn} do test "relay rejects a duplicate event with 409", %{conn: conn} do
# Given
event = valid_pub_event_fixture() event = valid_pub_event_fixture()
post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) 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)}) conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
# Then
assert json_response(conn, 409) assert json_response(conn, 409)
end 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 end
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -116,24 +65,19 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
describe "DELETE /api/events/:id — deletion" do describe "DELETE /api/events/:id — deletion" do
test "client deletes an existing event and gets 204", %{conn: conn} do test "client deletes an existing event and gets 204", %{conn: conn} do
# Given
event = valid_pub_event_fixture() event = valid_pub_event_fixture()
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event) {:ok, db_event} = GcIndexRelay.Nostr.create_event(event)
hex_id = Base.encode16(db_event.id, case: :lower) hex_id = Base.encode16(db_event.id, case: :lower)
# When
conn = delete(conn, ~p"/api/events/#{hex_id}") conn = delete(conn, ~p"/api/events/#{hex_id}")
# Then
assert conn.status == 204 assert conn.status == 204
assert conn.resp_body == "" assert conn.resp_body == ""
end end
test "deleting a non-existent event returns 404", %{conn: conn} do test "deleting a non-existent event returns 404", %{conn: conn} do
# When
conn = delete(conn, ~p"/api/events/#{String.duplicate("b", 64)}") conn = delete(conn, ~p"/api/events/#{String.duplicate("b", 64)}")
# Then
assert json_response(conn, 404) assert json_response(conn, 404)
end end
end end
@ -144,29 +88,18 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
describe "GET /api/events — cacheable query" do describe "GET /api/events — cacheable query" do
test "client fetches events with since/until/limit query params", %{conn: conn} do test "client fetches events with since/until/limit query params", %{conn: conn} do
# Given valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
_e1 = |> 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})
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2}) |> then(&GcIndexRelay.Nostr.create_event/1)
|> then(&GcIndexRelay.Nostr.create_event/1)
# When
conn = get(conn, "/api/events?since=0&until=9999999999&limit=10") conn = get(conn, "/api/events?since=0&until=9999999999&limit=10")
# Then
assert %{"data" => events} = json_response(conn, 200) assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 2 assert length(events) == 2
end [first, second] = events
assert first["created_at"] >= second["created_at"]
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)
end end
end end
@ -176,19 +109,14 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
describe "POST /api/events/filter — querying" do describe "POST /api/events/filter — querying" do
test "client fetches recent kind 1 notes, newest first", %{conn: conn} do test "client fetches recent kind 1 notes, newest first", %{conn: conn} do
# Given valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
_older = |> 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})
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2}) |> then(&GcIndexRelay.Nostr.create_event/1)
|> then(&GcIndexRelay.Nostr.create_event/1)
# When
conn = post(conn, ~p"/api/events/filter", %{"kinds" => [1], "limit" => 10}) conn = post(conn, ~p"/api/events/filter", %{"kinds" => [1], "limit" => 10})
# Then
assert %{"data" => events} = json_response(conn, 200) assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 2 assert length(events) == 2
[first, second] = events [first, second] = events
@ -196,13 +124,11 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
end end
test "client fetches a user profile (kind 0) by author", %{conn: conn} do test "client fetches a user profile (kind 0) by author", %{conn: conn} do
# Given
%{keypair1: kp} = test_keypairs() %{keypair1: kp} = test_keypairs()
metadata = Jason.encode!(%{name: "alice"}) metadata = Jason.encode!(%{name: "alice"})
event = valid_pub_event_fixture(%{kind: 0, content: metadata}) event = valid_pub_event_fixture(%{kind: 0, content: metadata})
{:ok, _} = GcIndexRelay.Nostr.create_event(event) {:ok, _} = GcIndexRelay.Nostr.create_event(event)
# When
conn = conn =
post(conn, ~p"/api/events/filter", %{ post(conn, ~p"/api/events/filter", %{
"kinds" => [0], "kinds" => [0],
@ -210,69 +136,43 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
"limit" => 1 "limit" => 1
}) })
# Then
assert %{"data" => [profile]} = json_response(conn, 200) assert %{"data" => [profile]} = json_response(conn, 200)
assert profile["kind"] == 0 assert profile["kind"] == 0
assert profile["pubkey"] == kp.public_key_hex assert profile["pubkey"] == kp.public_key_hex
end end
test "client fetches events mentioning a pubkey via #p tag", %{conn: conn} do 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) mentioned_pubkey = String.duplicate("ab", 32)
event = valid_pub_event_fixture(%{tags: [["p", mentioned_pubkey]]}) event = valid_pub_event_fixture(%{tags: [["p", mentioned_pubkey]]})
{:ok, _} = GcIndexRelay.Nostr.create_event(event) {:ok, _} = GcIndexRelay.Nostr.create_event(event)
_unrelated = valid_pub_event_fixture(%{created_at: 1_640_000_001, keypair: :keypair2})
valid_pub_event_fixture(%{created_at: 1_640_000_001, keypair: :keypair2}) |> then(&GcIndexRelay.Nostr.create_event/1)
|> then(&GcIndexRelay.Nostr.create_event/1)
# When
conn = post(conn, ~p"/api/events/filter", %{"#p" => [mentioned_pubkey], "limit" => 10}) 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 %{"data" => events} = json_response(conn, 200)
assert length(events) == 1 assert length(events) == 1
assert hd(events)["id"] == event.id assert hd(events)["id"] == event.id
end end
test "client fetches events within a time window", %{conn: conn} do test "client fetches events within a time window", %{conn: conn} do
# Given valid_pub_event_fixture(%{kind: 1, created_at: 1_500_000_000})
_old = |> 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})
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001, keypair: :keypair2}) |> then(&GcIndexRelay.Nostr.create_event/1)
|> then(&GcIndexRelay.Nostr.create_event/1)
# When — only events after 1_600_000_000
conn = conn =
post(conn, ~p"/api/events/filter", %{ post(conn, ~p"/api/events/filter", %{
"since" => 1_600_000_000, "since" => 1_600_000_000,
"limit" => 10 "limit" => 10
}) })
# Then
assert %{"data" => events} = json_response(conn, 200) assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 1 assert length(events) == 1
assert hd(events)["created_at"] == 1_640_000_001 assert hd(events)["created_at"] == 1_640_000_001
end 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 end
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -281,160 +181,21 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
describe "GET /api/events/:id — single event lookup" do describe "GET /api/events/:id — single event lookup" do
test "client fetches a specific event by ID", %{conn: conn} do test "client fetches a specific event by ID", %{conn: conn} do
# Given
event = valid_pub_event_fixture() event = valid_pub_event_fixture()
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event) {:ok, db_event} = GcIndexRelay.Nostr.create_event(event)
hex_id = Base.encode16(db_event.id, case: :lower) hex_id = Base.encode16(db_event.id, case: :lower)
# When
conn = get(conn, ~p"/api/events/#{hex_id}") conn = get(conn, ~p"/api/events/#{hex_id}")
# Then
assert %{"data" => data} = json_response(conn, 200) assert %{"data" => data} = json_response(conn, 200)
assert data["id"] == hex_id assert data["id"] == hex_id
assert data["content"] == event.content assert data["content"] == event.content
end end
test "fetching a non-existent event returns 404", %{conn: conn} do test "fetching a non-existent event returns 404", %{conn: conn} do
# When
conn = get(conn, ~p"/api/events/#{String.duplicate("a", 64)}") conn = get(conn, ~p"/api/events/#{String.duplicate("a", 64)}")
# Then
assert json_response(conn, 404) assert json_response(conn, 404)
end end
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 end

173
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
Loading…
Cancel
Save