mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-01-12 18:00:23 +00:00
1080 lines
32 KiB
Markdown
1080 lines
32 KiB
Markdown
# WandererApp Test Examples
|
|
|
|
This document provides practical examples of common test scenarios in the WandererApp project. Use these as templates when writing new tests.
|
|
|
|
## Table of Contents
|
|
|
|
1. [API Endpoint Tests](#api-endpoint-tests)
|
|
2. [Authentication Tests](#authentication-tests)
|
|
3. [Error Handling Tests](#error-handling-tests)
|
|
4. [Mock Usage Examples](#mock-usage-examples)
|
|
5. [Contract Test Examples](#contract-test-examples)
|
|
6. [Performance Test Examples](#performance-test-examples)
|
|
7. [WebSocket Test Examples](#websocket-test-examples)
|
|
|
|
## API Endpoint Tests
|
|
|
|
### Basic CRUD Operations
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.MapSystemsAPITest do
|
|
use WandererAppWeb.ConnCase, async: true
|
|
|
|
alias WandererApp.Test.Factory
|
|
|
|
describe "systems CRUD operations" do
|
|
setup do
|
|
user = Factory.create_user()
|
|
map = Factory.create_map(%{user_id: user.id})
|
|
api_key = Factory.create_map_api_key(%{map_id: map.id})
|
|
|
|
%{
|
|
map: map,
|
|
conn: build_conn() |> put_req_header("x-api-key", api_key.key)
|
|
}
|
|
end
|
|
|
|
test "lists all systems in a map", %{conn: conn, map: map} do
|
|
# Create test data
|
|
systems = for i <- 1..3 do
|
|
Factory.create_map_system(%{
|
|
map_id: map.id,
|
|
solar_system_id: 30000142 + i,
|
|
position_x: i * 100,
|
|
position_y: i * 100
|
|
})
|
|
end
|
|
|
|
# Make request
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems")
|
|
|
|
# Assert response
|
|
assert response = json_response(conn, 200)
|
|
assert length(response["data"]) == 3
|
|
|
|
# Verify each system
|
|
response_ids = Enum.map(response["data"], & &1["solar_system_id"])
|
|
expected_ids = Enum.map(systems, & &1.solar_system_id)
|
|
assert Enum.sort(response_ids) == Enum.sort(expected_ids)
|
|
|
|
# Check response structure
|
|
first_system = hd(response["data"])
|
|
assert first_system["type"] == "system"
|
|
assert first_system["position_x"]
|
|
assert first_system["position_y"]
|
|
end
|
|
|
|
test "creates a new system", %{conn: conn, map: map} do
|
|
system_params = %{
|
|
"solar_system_id" => 30000142,
|
|
"position_x" => 100,
|
|
"position_y" => 200,
|
|
"name" => "Jita",
|
|
"description" => "Trade hub"
|
|
}
|
|
|
|
conn = post(conn, "/api/maps/#{map.slug}/systems", system_params)
|
|
|
|
assert response = json_response(conn, 201)
|
|
assert response["data"]["solar_system_id"] == 30000142
|
|
assert response["data"]["name"] == "Jita"
|
|
|
|
# Verify location header
|
|
assert [location] = get_resp_header(conn, "location")
|
|
assert location =~ "/api/maps/#{map.slug}/systems/30000142"
|
|
|
|
# Verify system was actually created
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems/30000142")
|
|
assert json_response(conn, 200)
|
|
end
|
|
|
|
test "updates an existing system", %{conn: conn, map: map} do
|
|
system = Factory.create_map_system(map.id, %{
|
|
solar_system_id: 30000142,
|
|
position_x: 100,
|
|
position_y: 100
|
|
})
|
|
|
|
update_params = %{
|
|
"position_x" => 200,
|
|
"position_y" => 300,
|
|
"description" => "Updated position"
|
|
}
|
|
|
|
conn = put(conn, "/api/maps/#{map.slug}/systems/#{system.solar_system_id}", update_params)
|
|
|
|
assert response = json_response(conn, 200)
|
|
assert response["data"]["position_x"] == 200
|
|
assert response["data"]["position_y"] == 300
|
|
assert response["data"]["description"] == "Updated position"
|
|
end
|
|
|
|
test "deletes a system", %{conn: conn, map: map} do
|
|
system = Factory.create_map_system(map.id)
|
|
|
|
conn = delete(conn, "/api/maps/#{map.slug}/systems/#{system.solar_system_id}")
|
|
|
|
assert conn.status == 204
|
|
assert get_resp_header(conn, "content-length") == ["0"]
|
|
|
|
# Verify deletion
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems/#{system.solar_system_id}")
|
|
assert json_response(conn, 404)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
### Pagination and Filtering
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.PaginationTest do
|
|
use WandererAppWeb.ConnCase
|
|
|
|
describe "pagination" do
|
|
setup do
|
|
map = Factory.create_map()
|
|
|
|
# Create 25 systems
|
|
for i <- 1..25 do
|
|
Factory.create_map_system(map.id, %{
|
|
solar_system_id: 30000100 + i,
|
|
name: "System #{i}"
|
|
})
|
|
end
|
|
|
|
api_key = Factory.create_map_api_key(%{map_id: map.id})
|
|
|
|
%{
|
|
map: map,
|
|
conn: build_conn() |> put_req_header("x-api-key", api_key.key)
|
|
}
|
|
end
|
|
|
|
test "paginates results with limit and offset", %{conn: conn, map: map} do
|
|
# First page
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems?limit=10&offset=0")
|
|
page1 = json_response(conn, 200)
|
|
|
|
assert length(page1["data"]) == 10
|
|
assert page1["meta"]["total"] == 25
|
|
assert page1["meta"]["limit"] == 10
|
|
assert page1["meta"]["offset"] == 0
|
|
|
|
# Second page
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems?limit=10&offset=10")
|
|
page2 = json_response(conn, 200)
|
|
|
|
assert length(page2["data"]) == 10
|
|
assert page2["meta"]["offset"] == 10
|
|
|
|
# Ensure no overlap
|
|
page1_ids = Enum.map(page1["data"], & &1["solar_system_id"])
|
|
page2_ids = Enum.map(page2["data"], & &1["solar_system_id"])
|
|
assert Enum.empty?(page1_ids -- (page1_ids -- page2_ids))
|
|
|
|
# Last page
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems?limit=10&offset=20")
|
|
page3 = json_response(conn, 200)
|
|
|
|
assert length(page3["data"]) == 5
|
|
end
|
|
|
|
test "filters results by name", %{conn: conn, map: map} do
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems?filter[name]=System 1")
|
|
response = json_response(conn, 200)
|
|
|
|
# Should match "System 1", "System 10-19"
|
|
assert length(response["data"]) == 11
|
|
assert Enum.all?(response["data"], &String.contains?(&1["name"], "System 1"))
|
|
end
|
|
|
|
test "sorts results", %{conn: conn, map: map} do
|
|
# Sort by name ascending
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems?sort=name&limit=5")
|
|
response = json_response(conn, 200)
|
|
|
|
names = Enum.map(response["data"], & &1["name"])
|
|
assert names == Enum.sort(names)
|
|
|
|
# Sort by name descending
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems?sort=-name&limit=5")
|
|
response = json_response(conn, 200)
|
|
|
|
names = Enum.map(response["data"], & &1["name"])
|
|
assert names == Enum.sort(names, :desc)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
## Authentication Tests
|
|
|
|
### API Key Authentication
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.APIKeyAuthTest do
|
|
use WandererAppWeb.ConnCase
|
|
|
|
describe "API key authentication" do
|
|
setup do
|
|
user = Factory.create_user()
|
|
map = Factory.create_map(%{user_id: user.id})
|
|
%{map: map, user: user}
|
|
end
|
|
|
|
test "accepts valid API key in header", %{map: map} do
|
|
api_key = Factory.create_map_api_key(%{map_id: map.id})
|
|
|
|
conn =
|
|
build_conn()
|
|
|> put_req_header("x-api-key", api_key.key)
|
|
|> get("/api/maps/#{map.slug}")
|
|
|
|
assert json_response(conn, 200)
|
|
end
|
|
|
|
test "accepts valid API key in query params", %{map: map} do
|
|
api_key = Factory.create_map_api_key(%{map_id: map.id})
|
|
|
|
conn = get(build_conn(), "/api/maps/#{map.slug}?api_key=#{api_key.key}")
|
|
|
|
assert json_response(conn, 200)
|
|
end
|
|
|
|
test "rejects invalid API key", %{map: map} do
|
|
conn =
|
|
build_conn()
|
|
|> put_req_header("x-api-key", "invalid-key-12345")
|
|
|> get("/api/maps/#{map.slug}")
|
|
|
|
assert response = json_response(conn, 401)
|
|
assert response["errors"]["status"] == "401"
|
|
assert response["errors"]["title"] == "Unauthorized"
|
|
assert response["errors"]["detail"] =~ "Invalid API key"
|
|
end
|
|
|
|
test "rejects missing API key", %{map: map} do
|
|
conn = get(build_conn(), "/api/maps/#{map.slug}")
|
|
|
|
assert response = json_response(conn, 401)
|
|
assert response["errors"]["detail"] =~ "API key required"
|
|
end
|
|
|
|
test "rejects expired API key", %{map: map} do
|
|
# Create expired key
|
|
expired_key = Factory.create_map_api_key(%{
|
|
map_id: map.id,
|
|
expires_at: DateTime.add(DateTime.utc_now(), -3600, :second)
|
|
})
|
|
|
|
conn =
|
|
build_conn()
|
|
|> put_req_header("x-api-key", expired_key.key)
|
|
|> get("/api/maps/#{map.slug}")
|
|
|
|
assert response = json_response(conn, 401)
|
|
assert response["errors"]["detail"] =~ "API key expired"
|
|
end
|
|
|
|
test "rejects revoked API key", %{map: map} do
|
|
revoked_key = Factory.create_map_api_key(%{
|
|
map_id: map.id,
|
|
revoked: true
|
|
})
|
|
|
|
conn =
|
|
build_conn()
|
|
|> put_req_header("x-api-key", revoked_key.key)
|
|
|> get("/api/maps/#{map.slug}")
|
|
|
|
assert response = json_response(conn, 401)
|
|
assert response["errors"]["detail"] =~ "API key revoked"
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
### Permission Tests
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.PermissionTest do
|
|
use WandererAppWeb.ConnCase
|
|
|
|
describe "ACL permissions" do
|
|
setup do
|
|
owner = Factory.create_user()
|
|
member = Factory.create_user()
|
|
map = Factory.create_map(%{user_id: owner.id})
|
|
|
|
# Create ACL with member
|
|
acl = Factory.create_access_list(%{map_id: map.id})
|
|
Factory.create_access_list_member(%{
|
|
access_list_id: acl.id,
|
|
character_id: member.character_id,
|
|
role: "viewer"
|
|
})
|
|
|
|
# Create API keys
|
|
owner_key = Factory.create_map_api_key(%{map_id: map.id, user_id: owner.id})
|
|
acl_key = Factory.create_acl_api_key(%{access_list_id: acl.id})
|
|
|
|
%{
|
|
map: map,
|
|
owner: owner,
|
|
member: member,
|
|
owner_key: owner_key,
|
|
acl_key: acl_key
|
|
}
|
|
end
|
|
|
|
test "owner can perform all operations", %{map: map, owner_key: owner_key} do
|
|
conn = build_conn() |> put_req_header("x-api-key", owner_key.key)
|
|
|
|
# Can read
|
|
conn = get(conn, "/api/maps/#{map.slug}")
|
|
assert json_response(conn, 200)
|
|
|
|
# Can create
|
|
conn = post(conn, "/api/maps/#{map.slug}/systems", %{
|
|
"solar_system_id" => 30000142,
|
|
"position_x" => 100,
|
|
"position_y" => 200
|
|
})
|
|
assert json_response(conn, 201)
|
|
|
|
# Can update
|
|
conn = put(conn, "/api/maps/#{map.slug}", %{"name" => "Updated Name"})
|
|
assert json_response(conn, 200)
|
|
|
|
# Can delete
|
|
conn = delete(conn, "/api/maps/#{map.slug}/systems/30000142")
|
|
assert conn.status == 204
|
|
end
|
|
|
|
test "viewer can only read", %{map: map, acl_key: acl_key} do
|
|
conn = build_conn() |> put_req_header("x-api-key", acl_key.key)
|
|
|
|
# Can read
|
|
conn = get(conn, "/api/maps/#{map.slug}")
|
|
assert json_response(conn, 200)
|
|
|
|
# Cannot create
|
|
conn = post(conn, "/api/maps/#{map.slug}/systems", %{
|
|
"solar_system_id" => 30000142,
|
|
"position_x" => 100,
|
|
"position_y" => 200
|
|
})
|
|
assert response = json_response(conn, 403)
|
|
assert response["errors"]["detail"] =~ "permission" or
|
|
response["errors"]["detail"] =~ "forbidden"
|
|
|
|
# Cannot update
|
|
conn = put(conn, "/api/maps/#{map.slug}", %{"name" => "Updated Name"})
|
|
assert json_response(conn, 403)
|
|
|
|
# Cannot delete
|
|
conn = delete(conn, "/api/maps/#{map.slug}")
|
|
assert json_response(conn, 403)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
## Error Handling Tests
|
|
|
|
### Validation Errors
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.ValidationErrorTest do
|
|
use WandererAppWeb.ConnCase
|
|
|
|
describe "input validation" do
|
|
setup [:create_authenticated_conn]
|
|
|
|
test "validates required fields", %{conn: conn, map: map} do
|
|
# Missing required fields
|
|
conn = post(conn, "/api/maps/#{map.slug}/systems", %{})
|
|
|
|
assert response = json_response(conn, 422)
|
|
assert response["errors"]["status"] == "422"
|
|
assert response["errors"]["title"] == "Unprocessable Entity"
|
|
assert response["errors"]["detail"] =~ "required"
|
|
|
|
# Check for field-specific errors
|
|
assert response["errors"]["fields"]["solar_system_id"] =~ "required"
|
|
assert response["errors"]["fields"]["position_x"] =~ "required"
|
|
assert response["errors"]["fields"]["position_y"] =~ "required"
|
|
end
|
|
|
|
test "validates field types", %{conn: conn, map: map} do
|
|
invalid_params = %{
|
|
"solar_system_id" => "not-a-number",
|
|
"position_x" => "invalid",
|
|
"position_y" => [1, 2, 3]
|
|
}
|
|
|
|
conn = post(conn, "/api/maps/#{map.slug}/systems", invalid_params)
|
|
|
|
assert response = json_response(conn, 422)
|
|
assert response["errors"]["fields"]["solar_system_id"] =~ "must be an integer"
|
|
assert response["errors"]["fields"]["position_x"] =~ "must be a number"
|
|
assert response["errors"]["fields"]["position_y"] =~ "must be a number"
|
|
end
|
|
|
|
test "validates field constraints", %{conn: conn, map: map} do
|
|
params = %{
|
|
"solar_system_id" => -1, # Invalid EVE system ID
|
|
"position_x" => 99999999, # Too large
|
|
"position_y" => -99999999, # Too small
|
|
"name" => String.duplicate("a", 500) # Too long
|
|
}
|
|
|
|
conn = post(conn, "/api/maps/#{map.slug}/systems", params)
|
|
|
|
assert response = json_response(conn, 422)
|
|
assert response["errors"]["fields"]["solar_system_id"] =~ "must be positive"
|
|
assert response["errors"]["fields"]["name"] =~ "too long"
|
|
end
|
|
|
|
test "validates business rules", %{conn: conn, map: map} do
|
|
# Create a system
|
|
Factory.create_map_system(map.id, %{solar_system_id: 30000142})
|
|
|
|
# Try to create duplicate
|
|
conn = post(conn, "/api/maps/#{map.slug}/systems", %{
|
|
"solar_system_id" => 30000142,
|
|
"position_x" => 100,
|
|
"position_y" => 200
|
|
})
|
|
|
|
assert response = json_response(conn, 422)
|
|
assert response["errors"]["detail"] =~ "already exists" or
|
|
response["errors"]["detail"] =~ "duplicate"
|
|
end
|
|
end
|
|
|
|
defp create_authenticated_conn(_) do
|
|
map = Factory.create_map()
|
|
api_key = Factory.create_map_api_key(%{map_id: map.id})
|
|
|
|
%{
|
|
map: map,
|
|
conn: build_conn() |> put_req_header("x-api-key", api_key.key)
|
|
}
|
|
end
|
|
end
|
|
```
|
|
|
|
### Service Error Handling
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.ServiceErrorTest do
|
|
use WandererAppWeb.ConnCase
|
|
import Mox
|
|
|
|
setup :verify_on_exit!
|
|
|
|
describe "external service errors" do
|
|
setup [:create_authenticated_conn]
|
|
|
|
test "handles EVE API timeout", %{conn: conn} do
|
|
Test.EVEAPIClientMock
|
|
|> expect(:get_system_info, fn _system_id ->
|
|
{:error, :timeout}
|
|
end)
|
|
|
|
conn = get(conn, "/api/common/systems/30000142")
|
|
|
|
assert response = json_response(conn, 503)
|
|
assert response["errors"]["status"] == "503"
|
|
assert response["errors"]["title"] == "Service Unavailable"
|
|
assert response["errors"]["detail"] =~ "temporarily unavailable"
|
|
|
|
# Should include retry information
|
|
assert response["errors"]["meta"]["retry_after"]
|
|
end
|
|
|
|
test "handles database connection errors", %{conn: conn, map: map} do
|
|
# This is harder to test without actually breaking the DB
|
|
# In practice, you might use a custom Repo wrapper for testing
|
|
|
|
# Simulate by mocking Ecto.Adapters.SQL
|
|
Test.RepoMock
|
|
|> expect(:all, fn _query ->
|
|
{:error, %DBConnection.ConnectionError{message: "connection timeout"}}
|
|
end)
|
|
|
|
conn = get(conn, "/api/maps/#{map.slug}/systems")
|
|
|
|
assert response = json_response(conn, 503)
|
|
assert response["errors"]["detail"] =~ "database" or
|
|
response["errors"]["detail"] =~ "connection"
|
|
end
|
|
|
|
test "handles cache failures gracefully", %{conn: conn, map: map} do
|
|
Test.CacheMock
|
|
|> expect(:get, fn _key ->
|
|
{:error, :connection_refused}
|
|
end)
|
|
|> stub(:put, fn _key, _value, _opts ->
|
|
{:error, :connection_refused}
|
|
end)
|
|
|
|
# Should still work without cache
|
|
conn = get(conn, "/api/maps/#{map.slug}")
|
|
assert json_response(conn, 200)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
## Mock Usage Examples
|
|
|
|
### Complex Mock Scenarios
|
|
|
|
```elixir
|
|
defmodule WandererApp.ComplexMockTest do
|
|
use WandererApp.DataCase
|
|
import Mox
|
|
|
|
setup :verify_on_exit!
|
|
|
|
describe "complex service interactions" do
|
|
test "fetches and caches character with corporation info" do
|
|
character_id = 123456789
|
|
corporation_id = 987654321
|
|
|
|
# Mock EVE API calls in sequence
|
|
Test.EVEAPIClientMock
|
|
|> expect(:get_character_info, fn ^character_id ->
|
|
{:ok, %{
|
|
"name" => "Test Character",
|
|
"corporation_id" => corporation_id,
|
|
"birthday" => "2020-01-01T00:00:00Z"
|
|
}}
|
|
end)
|
|
|> expect(:get_corporation_info, fn ^corporation_id ->
|
|
{:ok, %{
|
|
"name" => "Test Corporation",
|
|
"ticker" => "TEST",
|
|
"member_count" => 100
|
|
}}
|
|
end)
|
|
|
|
# Mock cache interactions
|
|
Test.CacheMock
|
|
|> expect(:get, fn key ->
|
|
assert key in ["character:#{character_id}", "corporation:#{corporation_id}"]
|
|
{:error, :not_found}
|
|
end)
|
|
|> expect(:put, 2, fn key, value, opts ->
|
|
assert key in ["character:#{character_id}", "corporation:#{corporation_id}"]
|
|
assert opts[:ttl] in [3600, 7200]
|
|
assert is_map(value)
|
|
:ok
|
|
end)
|
|
|
|
# Mock PubSub notification
|
|
Test.PubSubMock
|
|
|> expect(:publish, fn topic, message ->
|
|
assert topic == "character:updates"
|
|
assert message.character_id == character_id
|
|
:ok
|
|
end)
|
|
|
|
# Execute the function
|
|
{:ok, character} = WandererApp.Characters.fetch_character_with_corp(character_id)
|
|
|
|
# Verify the result
|
|
assert character.name == "Test Character"
|
|
assert character.corporation.name == "Test Corporation"
|
|
assert character.corporation.ticker == "TEST"
|
|
end
|
|
|
|
test "handles partial failures with fallbacks" do
|
|
Test.EVEAPIClientMock
|
|
|> expect(:get_character_info, fn _id ->
|
|
{:ok, %{"name" => "Test Character"}}
|
|
end)
|
|
|> expect(:get_character_location, fn _id ->
|
|
{:error, :rate_limited}
|
|
end)
|
|
|
|
Test.CacheMock
|
|
|> expect(:get, fn "character:location:123" ->
|
|
# Return cached location
|
|
{:ok, %{solar_system_id: 30000142, last_updated: DateTime.utc_now()}}
|
|
end)
|
|
|
|
{:ok, character} = WandererApp.Characters.fetch_character_full(123)
|
|
|
|
assert character.name == "Test Character"
|
|
assert character.location.solar_system_id == 30000142
|
|
assert character.location.source == :cache
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
### Stub vs Expect
|
|
|
|
```elixir
|
|
defmodule WandererApp.StubVsExpectTest do
|
|
use WandererApp.DataCase
|
|
import Mox
|
|
|
|
describe "when to use stub vs expect" do
|
|
test "use stub for optional background operations" do
|
|
# Logger calls are optional - use stub
|
|
Test.LoggerMock
|
|
|> stub(:info, fn _msg -> :ok end)
|
|
|> stub(:debug, fn _msg -> :ok end)
|
|
|
|
# Cache writes are optional - use stub
|
|
Test.CacheMock
|
|
|> stub(:put, fn _key, _value, _opts -> :ok end)
|
|
|
|
# Business logic doesn't fail if these don't happen
|
|
assert {:ok, _result} = WandererApp.SomeModule.do_work()
|
|
end
|
|
|
|
test "use expect for critical operations" do
|
|
# This MUST be called exactly once
|
|
Test.EVEAPIClientMock
|
|
|> expect(:verify_token, 1, fn token ->
|
|
assert token == "test-token"
|
|
{:ok, %{character_id: 123}}
|
|
end)
|
|
|
|
# This MUST be called with specific params
|
|
Test.DatabaseMock
|
|
|> expect(:insert_character, fn character ->
|
|
assert character.id == 123
|
|
{:ok, character}
|
|
end)
|
|
|
|
# Test will fail if expectations aren't met
|
|
assert {:ok, _} = WandererApp.Auth.verify_and_create("test-token")
|
|
end
|
|
|
|
test "combine stub and expect" do
|
|
# Required call
|
|
Test.EVEAPIClientMock
|
|
|> expect(:get_character_info, fn _id ->
|
|
{:ok, %{name: "Test"}}
|
|
end)
|
|
|
|
# Optional calls that might happen 0+ times
|
|
Test.CacheMock
|
|
|> stub(:get, fn _key -> {:error, :not_found} end)
|
|
|> stub(:put, fn _key, _value, _opts -> :ok end)
|
|
|
|
Test.LoggerMock
|
|
|> stub(:info, fn _msg -> :ok end)
|
|
|
|
assert {:ok, _} = WandererApp.Characters.fetch_character(123)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
## Contract Test Examples
|
|
|
|
### OpenAPI Validation
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.OpenAPIContractTest do
|
|
use WandererAppWeb.ConnCase
|
|
use WandererAppWeb.OpenAPICase
|
|
|
|
describe "API contract validation" do
|
|
setup [:create_test_data]
|
|
|
|
test "validates all endpoints against OpenAPI spec", %{conn: conn, map: map} do
|
|
# Test each endpoint defined in OpenAPI spec
|
|
for operation <- get_all_operations() do
|
|
test_operation_contract(conn, operation, %{
|
|
map_slug: map.slug,
|
|
system_id: "30000142"
|
|
})
|
|
end
|
|
end
|
|
|
|
test "POST /api/maps/:slug/systems matches schema", %{conn: conn, map: map} do
|
|
request_body = %{
|
|
"solar_system_id" => 30000142,
|
|
"position_x" => 100.5,
|
|
"position_y" => 200.5,
|
|
"name" => "Jita",
|
|
"description" => "Major trade hub",
|
|
"locked" => false,
|
|
"rally_point" => true
|
|
}
|
|
|
|
# Validate request matches schema
|
|
assert_valid_request_body(request_body, "CreateSystemRequest")
|
|
|
|
# Make request
|
|
conn =
|
|
conn
|
|
|> put_req_header("content-type", "application/json")
|
|
|> post("/api/maps/#{map.slug}/systems", request_body)
|
|
|
|
# Validate response
|
|
assert response = json_response(conn, 201)
|
|
assert_valid_response(response, 201, "CreateSystemResponse")
|
|
|
|
# Validate response headers
|
|
assert_required_headers(conn, ["content-type", "location"])
|
|
assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
|
|
|
|
# Validate response data types
|
|
assert is_integer(response["data"]["solar_system_id"])
|
|
assert is_float(response["data"]["position_x"])
|
|
assert is_boolean(response["data"]["locked"])
|
|
assert is_binary(response["data"]["created_at"])
|
|
assert DateTime.from_iso8601(response["data"]["created_at"])
|
|
end
|
|
|
|
test "error responses match error schema", %{conn: conn} do
|
|
# Test various error scenarios
|
|
error_cases = [
|
|
{"/api/maps/nonexistent", 404, "Not Found"},
|
|
{"/api/maps/test", 401, "Unauthorized"}, # No API key
|
|
]
|
|
|
|
for {path, status, title} <- error_cases do
|
|
conn = get(build_conn(), path)
|
|
|
|
assert response = json_response(conn, status)
|
|
assert_valid_response(response, status, "ErrorResponse")
|
|
|
|
assert response["errors"]["status"] == to_string(status)
|
|
assert response["errors"]["title"] == title
|
|
assert is_binary(response["errors"]["detail"])
|
|
assert is_binary(response["errors"]["id"])
|
|
end
|
|
end
|
|
end
|
|
|
|
defp test_operation_contract(conn, operation, params) do
|
|
path = build_path(operation.path, params)
|
|
|
|
case operation.method do
|
|
:get ->
|
|
conn = get(conn, path)
|
|
assert conn.status in operation.expected_statuses
|
|
|
|
:post ->
|
|
body = build_request_body(operation.request_schema)
|
|
conn = post(conn, path, body)
|
|
assert conn.status in operation.expected_statuses
|
|
|
|
_ ->
|
|
# Handle other methods
|
|
end
|
|
|
|
if conn.status < 400 do
|
|
response = json_response(conn, conn.status)
|
|
assert_valid_response(response, conn.status, operation.response_schema)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
## Performance Test Examples
|
|
|
|
### Load Testing
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.PerformanceTest do
|
|
use WandererAppWeb.ConnCase
|
|
|
|
@tag :performance
|
|
@tag timeout: :infinity
|
|
describe "API performance under load" do
|
|
setup do
|
|
# Create test data
|
|
map = Factory.create_map()
|
|
|
|
# Create many systems
|
|
systems = for i <- 1..1000 do
|
|
Factory.create_map_system(map.id, %{
|
|
solar_system_id: 30000000 + i
|
|
})
|
|
end
|
|
|
|
api_key = Factory.create_map_api_key(%{map_id: map.id})
|
|
|
|
%{
|
|
map: map,
|
|
systems: systems,
|
|
api_key: api_key
|
|
}
|
|
end
|
|
|
|
test "handles concurrent read requests", %{map: map, api_key: api_key} do
|
|
# Warm up
|
|
conn =
|
|
build_conn()
|
|
|> put_req_header("x-api-key", api_key.key)
|
|
|> get("/api/maps/#{map.slug}/systems")
|
|
assert json_response(conn, 200)
|
|
|
|
# Measure concurrent performance
|
|
concurrency_levels = [10, 50, 100]
|
|
|
|
for concurrency <- concurrency_levels do
|
|
{time, results} = :timer.tc(fn ->
|
|
tasks = for _ <- 1..concurrency do
|
|
Task.async(fn ->
|
|
conn =
|
|
build_conn()
|
|
|> put_req_header("x-api-key", api_key.key)
|
|
|> get("/api/maps/#{map.slug}/systems")
|
|
|
|
{conn.status, byte_size(conn.resp_body)}
|
|
end)
|
|
end
|
|
|
|
Task.await_many(tasks, 30_000)
|
|
end)
|
|
|
|
# All should succeed
|
|
assert Enum.all?(results, fn {status, _size} -> status == 200 end)
|
|
|
|
# Calculate metrics
|
|
avg_time = time / concurrency / 1000 # ms
|
|
requests_per_sec = concurrency * 1_000_000 / time
|
|
|
|
IO.puts("Concurrency: #{concurrency}")
|
|
IO.puts(" Total time: #{time / 1_000}ms")
|
|
IO.puts(" Avg time per request: #{Float.round(avg_time, 2)}ms")
|
|
IO.puts(" Requests/sec: #{Float.round(requests_per_sec, 2)}")
|
|
|
|
# Performance assertions
|
|
assert avg_time < 100, "Average response time should be under 100ms"
|
|
assert requests_per_sec > 10, "Should handle at least 10 requests/sec"
|
|
end
|
|
end
|
|
|
|
test "handles large response payloads efficiently", %{map: map, api_key: api_key} do
|
|
# Request all systems (1000 items)
|
|
{time, conn} = :timer.tc(fn ->
|
|
build_conn()
|
|
|> put_req_header("x-api-key", api_key.key)
|
|
|> get("/api/maps/#{map.slug}/systems?limit=1000")
|
|
end)
|
|
|
|
assert response = json_response(conn, 200)
|
|
assert length(response["data"]) == 1000
|
|
|
|
# Check performance
|
|
response_size = byte_size(conn.resp_body)
|
|
time_ms = time / 1000
|
|
|
|
IO.puts("Large payload performance:")
|
|
IO.puts(" Response size: #{response_size / 1024}KB")
|
|
IO.puts(" Response time: #{time_ms}ms")
|
|
IO.puts(" Throughput: #{Float.round(response_size / time * 1000, 2)}KB/s")
|
|
|
|
# Should complete in reasonable time
|
|
assert time_ms < 1000, "Large payload should return in under 1 second"
|
|
end
|
|
|
|
test "write operations maintain performance", %{map: map, api_key: api_key} do
|
|
write_times = for i <- 1..10 do
|
|
system_params = %{
|
|
"solar_system_id" => 31000000 + i,
|
|
"position_x" => i * 10,
|
|
"position_y" => i * 10
|
|
}
|
|
|
|
{time, conn} = :timer.tc(fn ->
|
|
build_conn()
|
|
|> put_req_header("x-api-key", api_key.key)
|
|
|> put_req_header("content-type", "application/json")
|
|
|> post("/api/maps/#{map.slug}/systems", system_params)
|
|
end)
|
|
|
|
assert json_response(conn, 201)
|
|
time / 1000 # Convert to ms
|
|
end
|
|
|
|
avg_write_time = Enum.sum(write_times) / length(write_times)
|
|
max_write_time = Enum.max(write_times)
|
|
|
|
IO.puts("Write operation performance:")
|
|
IO.puts(" Average time: #{Float.round(avg_write_time, 2)}ms")
|
|
IO.puts(" Max time: #{Float.round(max_write_time, 2)}ms")
|
|
|
|
assert avg_write_time < 200, "Writes should average under 200ms"
|
|
assert max_write_time < 500, "No write should take over 500ms"
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
## WebSocket Test Examples
|
|
|
|
### Real-time Updates
|
|
|
|
```elixir
|
|
defmodule WandererAppWeb.WebSocketTest do
|
|
use WandererAppWeb.ChannelCase
|
|
|
|
alias WandererAppWeb.MapChannel
|
|
|
|
describe "map real-time updates" do
|
|
setup do
|
|
user = Factory.create_user()
|
|
map = Factory.create_map(%{user_id: user.id})
|
|
|
|
# Connect to channel
|
|
{:ok, socket} = connect(WandererAppWeb.UserSocket, %{
|
|
"token" => generate_user_token(user)
|
|
})
|
|
|
|
{:ok, _reply, socket} = subscribe_and_join(
|
|
socket,
|
|
MapChannel,
|
|
"map:#{map.slug}",
|
|
%{}
|
|
)
|
|
|
|
%{socket: socket, map: map, user: user}
|
|
end
|
|
|
|
test "broadcasts system creation", %{socket: socket, map: map} do
|
|
# Create system via API (would trigger broadcast)
|
|
system_data = %{
|
|
solar_system_id: 30000142,
|
|
position_x: 100,
|
|
position_y: 200,
|
|
name: "Jita"
|
|
}
|
|
|
|
# Simulate the broadcast that would happen
|
|
broadcast_from!(socket, "system:created", %{
|
|
"system" => system_data
|
|
})
|
|
|
|
# Client should receive the event
|
|
assert_push "system:created", %{system: pushed_system}
|
|
assert pushed_system.solar_system_id == 30000142
|
|
assert pushed_system.name == "Jita"
|
|
end
|
|
|
|
test "broadcasts system updates to all connected clients", %{map: map} do
|
|
# Connect multiple clients
|
|
clients = for i <- 1..3 do
|
|
user = Factory.create_user()
|
|
{:ok, socket} = connect(WandererAppWeb.UserSocket, %{
|
|
"token" => generate_user_token(user)
|
|
})
|
|
|
|
{:ok, _reply, socket} = subscribe_and_join(
|
|
socket,
|
|
MapChannel,
|
|
"map:#{map.slug}",
|
|
%{}
|
|
)
|
|
|
|
{user, socket}
|
|
end
|
|
|
|
# Broadcast update from first client
|
|
{_user1, socket1} = hd(clients)
|
|
|
|
broadcast_from!(socket1, "system:updated", %{
|
|
"system_id" => 30000142,
|
|
"changes" => %{"position_x" => 150}
|
|
})
|
|
|
|
# All other clients should receive it
|
|
for {_user, socket} <- tl(clients) do
|
|
assert_push "system:updated", payload, 1000
|
|
assert payload.system_id == 30000142
|
|
assert payload.changes.position_x == 150
|
|
end
|
|
end
|
|
|
|
test "handles presence tracking", %{socket: socket, map: map} do
|
|
# Track user presence
|
|
{:ok, _} = WandererAppWeb.Presence.track(
|
|
socket,
|
|
socket.assigns.user_id,
|
|
%{
|
|
character_name: "Test Character",
|
|
online_at: System.system_time(:second)
|
|
}
|
|
)
|
|
|
|
# Should receive presence state
|
|
assert_push "presence_state", state
|
|
assert map_size(state) == 1
|
|
|
|
# Another user joins
|
|
user2 = Factory.create_user()
|
|
{:ok, socket2} = connect(WandererAppWeb.UserSocket, %{
|
|
"token" => generate_user_token(user2)
|
|
})
|
|
|
|
{:ok, _reply, socket2} = subscribe_and_join(
|
|
socket2,
|
|
MapChannel,
|
|
"map:#{map.slug}",
|
|
%{}
|
|
)
|
|
|
|
# Should receive presence diff
|
|
assert_push "presence_diff", %{joins: joins, leaves: leaves}
|
|
assert map_size(joins) == 1
|
|
assert leaves == %{}
|
|
|
|
# User leaves
|
|
Process.unlink(socket2.channel_pid)
|
|
ref = leave(socket2)
|
|
assert_reply ref, :ok
|
|
|
|
# Should receive leave event
|
|
assert_push "presence_diff", %{joins: joins, leaves: leaves}
|
|
assert joins == %{}
|
|
assert map_size(leaves) == 1
|
|
end
|
|
|
|
test "authorizes actions based on permissions", %{socket: socket, map: map} do
|
|
# Try to delete system as non-owner
|
|
ref = push(socket, "system:delete", %{"system_id" => 30000142})
|
|
|
|
assert_reply ref, :error, %{reason: "unauthorized"}
|
|
|
|
# Should not broadcast to others
|
|
refute_push "system:deleted", _
|
|
end
|
|
end
|
|
|
|
defp generate_user_token(user) do
|
|
# Generate a Phoenix token for the user
|
|
Phoenix.Token.sign(WandererAppWeb.Endpoint, "user socket", user.id)
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
These examples demonstrate the various testing patterns used in the WandererApp project. Each example includes:
|
|
|
|
1. **Setup**: Creating necessary test data
|
|
2. **Execution**: Performing the action being tested
|
|
3. **Assertions**: Verifying the expected behavior
|
|
4. **Cleanup**: Handled automatically by ExUnit
|
|
|
|
Remember to:
|
|
- Use descriptive test names
|
|
- Keep tests focused and independent
|
|
- Mock external dependencies
|
|
- Test both success and failure cases
|
|
- Validate API contracts
|
|
- Monitor performance characteristics |