Browse Source

implement task #5

test/local-setup
Silberengel 3 weeks ago
parent
commit
3e4dc646fc
  1. 121
      test/features/relay_api.feature
  2. 295
      test/gc_index_relay_web/relay_integration_test.exs

121
test/features/relay_api.feature

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
Feature: Nostr Relay REST API
As a Nostr client (e.g. Jumble at https://jumble.imwald.eu/)
I want to interact with the relay over HTTP
So that I can publish and query Nostr events
Background:
Given the relay is running at http://localhost:4000
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
Scenario: Client discovers available endpoints
When the client sends GET /api
Then the response status is 200
And the response lists the available endpoints
# ---------------------------------------------------------------------------
# Publishing events
# ---------------------------------------------------------------------------
Scenario: Client publishes a valid kind 1 note
Given a valid signed kind 1 event from keypair 1
When the client sends POST /api/events with the event body
Then the response status is 201 Created
And the response body contains the published event
Scenario: Client publishes a kind 0 profile event with metadata
Given a valid signed kind 0 event containing a JSON metadata content field
When the client sends POST /api/events with the event body
Then the response status is 201 Created
Scenario: Relay rejects a duplicate event
Given a valid signed event has already been published
When the client sends POST /api/events with the same event again
Then the response status is 409 Conflict
Scenario: Relay rejects an event with an invalid ID
Given a signed event whose ID does not match the hash of its content
When the client sends POST /api/events with the event body
Then the response status is 400 Bad Request
Scenario: Relay rejects a NIP-70 protected event
Given a valid signed event with a ["-"] protection tag
When the client sends POST /api/events with the event body
Then the response status is 400 Bad Request
And the response error contains "auth-required"
# ---------------------------------------------------------------------------
# Fetching events (POST /api/events/filter — what most Nostr clients use)
# ---------------------------------------------------------------------------
Scenario: Client fetches recent notes (kind 1)
Given two kind 1 notes have been published
When the client sends POST /api/events/filter with body {"kinds": [1], "limit": 10}
Then the response status is 200
And the response contains both kind 1 events
And events are ordered newest first
Scenario: Client fetches a user profile (kind 0)
Given a kind 0 profile event has been published for keypair 1
When the client sends POST /api/events/filter with body {"kinds": [0], "authors": ["<pubkey1>"], "limit": 1}
Then the response status is 200
And the response contains exactly 1 event
And the event kind is 0
Scenario: Client fetches events mentioning a specific pubkey (#p tag)
Given an event tagged with ["p", "<some pubkey>"] has been published
When the client sends POST /api/events/filter with body {"#p": ["<some pubkey>"], "limit": 10}
Then the response status is 200
And the response contains the tagged event
Scenario: Client fetches events within a time window
Given two events with different created_at timestamps exist
When the client sends POST /api/events/filter with a "since" before the newer event
Then only the newer event is returned
Scenario: Filter without a limit is rejected
When the client sends POST /api/events/filter with body {"kinds": [1]}
Then the response status is 400 Bad Request
Scenario: Filter with a limit over 100 is rejected
When the client sends POST /api/events/filter with body {"limit": 101}
Then the response status is 400 Bad Request
# ---------------------------------------------------------------------------
# Fetching a single event by ID (GET /api/events/:id)
# ---------------------------------------------------------------------------
Scenario: Client fetches a specific event by ID
Given a known event has been published
When the client sends GET /api/events/<event_id>
Then the response status is 200
And the response body contains the correct event
Scenario: Fetching a non-existent event returns 404
When the client sends GET /api/events/<unknown_id>
Then the response status is 404 Not Found
# ---------------------------------------------------------------------------
# CORS — required for browser-based clients like Jumble
# ---------------------------------------------------------------------------
Scenario: Relay includes CORS headers on API responses
When the client sends any request to the API
Then the response includes "Access-Control-Allow-Origin: *"
And the response includes "Access-Control-Allow-Methods" listing GET, POST, DELETE, OPTIONS
Scenario: Relay handles a browser preflight OPTIONS request
When the client sends OPTIONS /api/events
Then the response status is 200
And the response includes CORS headers
And the response body is empty
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
Scenario: Health check returns OK
When the client sends GET /health
Then the response status is 200

295
test/gc_index_relay_web/relay_integration_test.exs

@ -0,0 +1,295 @@ @@ -0,0 +1,295 @@
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.
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
"""
use GcIndexRelayWeb.ConnCase
import GcIndexRelay.NostrFixtures
@moduletag :integration
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
# 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
assert data["content"] == "hello nostr"
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
# ---------------------------------------------------------------------------
# Querying events (POST /api/events/filter)
# ---------------------------------------------------------------------------
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)
_newer =
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
assert first["created_at"] >= second["created_at"]
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],
"authors" => [kp.public_key_hex],
"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)
# 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)
_new =
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
# ---------------------------------------------------------------------------
# Fetching a single event (GET /api/events/:id)
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# 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