mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-01-15 03:10:30 +00:00
454 lines
13 KiB
Plaintext
454 lines
13 KiB
Plaintext
defmodule WandererAppWeb.OpenAPITestGenerator do
|
|
@moduledoc """
|
|
Auto-generates contract tests from OpenAPI specifications.
|
|
|
|
This module creates comprehensive test cases for all documented
|
|
API operations, ensuring complete contract coverage.
|
|
"""
|
|
|
|
alias WandererAppWeb.OpenAPISpecAnalyzer
|
|
|
|
@doc """
|
|
Generates test modules for all API operations.
|
|
"""
|
|
def generate_all_tests(output_dir \\ "test/contract/generated") do
|
|
spec = OpenAPISpecAnalyzer.load_spec()
|
|
operations = OpenAPISpecAnalyzer.list_all_operations(spec)
|
|
|
|
# Group operations by controller
|
|
grouped_ops = Enum.group_by(operations, &extract_controller_name/1)
|
|
|
|
# Create output directory
|
|
File.mkdir_p!(output_dir)
|
|
|
|
# Generate test file for each controller
|
|
Enum.each(grouped_ops, fn {controller, ops} ->
|
|
generate_controller_tests(controller, ops, spec, output_dir)
|
|
end)
|
|
|
|
# Generate a summary test that validates the spec itself
|
|
generate_spec_validation_test(spec, output_dir)
|
|
|
|
{:ok, length(grouped_ops)}
|
|
end
|
|
|
|
@doc """
|
|
Generates test cases for a specific operation.
|
|
"""
|
|
def generate_operation_tests(operation_id, spec \\ nil) do
|
|
spec = spec || OpenAPISpecAnalyzer.load_spec()
|
|
|
|
operation = find_operation(spec, operation_id)
|
|
|
|
unless operation do
|
|
raise "Operation #{operation_id} not found in spec"
|
|
end
|
|
|
|
generate_test_cases(operation, spec)
|
|
end
|
|
|
|
@doc """
|
|
Generates example requests for an operation.
|
|
"""
|
|
def generate_example_requests(operation_id, spec \\ nil) do
|
|
spec = spec || OpenAPISpecAnalyzer.load_spec()
|
|
operation = find_operation(spec, operation_id)
|
|
|
|
%{
|
|
valid: generate_valid_request(operation, spec),
|
|
invalid: generate_invalid_requests(operation, spec)
|
|
}
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp generate_controller_tests(controller_name, operations, spec, output_dir) do
|
|
module_name = "#{controller_name}ContractTest"
|
|
file_path = Path.join(output_dir, "#{Macro.underscore(controller_name)}_contract_test.exs")
|
|
|
|
test_content = """
|
|
defmodule WandererAppWeb.#{module_name} do
|
|
use WandererAppWeb.ApiCase, async: true
|
|
import WandererAppWeb.OpenAPIContractHelpers
|
|
|
|
@moduledoc \"\"\"
|
|
Auto-generated contract tests for #{controller_name}.
|
|
|
|
Generated on: #{DateTime.utc_now() |> DateTime.to_string()}
|
|
Operations covered: #{length(operations)}
|
|
\"\"\"
|
|
|
|
#{generate_operation_test_functions(operations, spec)}
|
|
end
|
|
"""
|
|
|
|
File.write!(file_path, test_content)
|
|
end
|
|
|
|
defp generate_operation_test_functions(operations, spec) do
|
|
operations
|
|
|> Enum.map(fn op -> generate_operation_test_function(op, spec) end)
|
|
|> Enum.join("\n\n")
|
|
end
|
|
|
|
defp generate_operation_test_function(operation, spec) do
|
|
test_name = operation.operation_id || "#{operation.method}_#{operation.path}"
|
|
|
|
"""
|
|
describe "#{test_name}" do
|
|
@tag :contract
|
|
test "validates successful response schema" do
|
|
# TODO: Set up test data
|
|
|
|
conn =
|
|
build_conn()
|
|
#{generate_auth_setup(operation)}
|
|
|> #{operation.method}("#{operation.path}"#{generate_params(operation, spec)})
|
|
|
|
assert conn.status in [200, 201, 204]
|
|
|
|
# Validate response schema
|
|
if conn.status != 204 do
|
|
assert_response_schema(conn, conn.status, nil, operation_id: "#{test_name}")
|
|
end
|
|
end
|
|
|
|
#{generate_error_tests(operation, spec)}
|
|
|
|
#{generate_parameter_tests(operation, spec)}
|
|
end
|
|
"""
|
|
end
|
|
|
|
defp generate_auth_setup(%{security: nil}), do: ""
|
|
defp generate_auth_setup(%{security: []}), do: ""
|
|
defp generate_auth_setup(_operation) do
|
|
"""
|
|
|> put_req_header("authorization", "Bearer \#{valid_api_key()}")"""
|
|
end
|
|
|
|
defp generate_params(%{has_request_body: true}, _spec) do
|
|
", %{}" # TODO: Generate valid request body
|
|
end
|
|
|
|
defp generate_params(_, _), do: ""
|
|
|
|
defp generate_error_tests(operation, _spec) do
|
|
error_responses =
|
|
operation.responses
|
|
|> Map.keys()
|
|
|> Enum.filter(&String.starts_with?(&1, "4"))
|
|
|
|
if error_responses == [] do
|
|
""
|
|
else
|
|
"""
|
|
# @tag :contract # Note: tags should be added outside generated code
|
|
test "validates error response schemas" do
|
|
# Test common error scenarios
|
|
conn =
|
|
build_conn()
|
|
|> #{operation.method}("#{operation.path}", %{invalid: "data"})
|
|
|
|
assert conn.status >= 400
|
|
assert_error_response(conn, conn.status)
|
|
end
|
|
"""
|
|
end
|
|
end
|
|
|
|
defp generate_parameter_tests(%{parameters: []}, _spec), do: ""
|
|
|
|
defp generate_parameter_tests(operation, _spec) do
|
|
"""
|
|
@tag :contract
|
|
test "validates parameter schemas" do
|
|
params = %{
|
|
#{generate_parameter_map(operation.parameters)}
|
|
}
|
|
|
|
assert_parameters(params, "#{operation.operation_id}")
|
|
end
|
|
"""
|
|
end
|
|
|
|
defp generate_parameter_map(parameters) do
|
|
parameters
|
|
|> Enum.map(fn param ->
|
|
"#{param.name}: #{generate_param_value(param)}"
|
|
end)
|
|
|> Enum.join(",\n ")
|
|
end
|
|
|
|
defp generate_param_value(%{schema: %{type: :string}}), do: ~s("test_value")
|
|
defp generate_param_value(%{schema: %{type: :integer}}), do: "123"
|
|
defp generate_param_value(%{schema: %{type: :boolean}}), do: "true"
|
|
defp generate_param_value(_), do: "nil"
|
|
|
|
defp generate_spec_validation_test(spec, output_dir) do
|
|
file_path = Path.join(output_dir, "api_spec_validation_test.exs")
|
|
|
|
test_content = """
|
|
defmodule WandererAppWeb.ApiSpecValidationTest do
|
|
use ExUnit.Case, async: true
|
|
import WandererAppWeb.OpenAPIContractHelpers
|
|
|
|
@moduledoc \"\"\"
|
|
Validates the OpenAPI specification itself.
|
|
\"\"\"
|
|
|
|
describe "API Specification" do
|
|
test "has valid metadata" do
|
|
spec = api_spec()
|
|
|
|
assert spec.info.title != nil
|
|
assert spec.info.version != nil
|
|
assert spec.openapi =~ ~r/^3\\.\\d+\\.\\d+$/
|
|
end
|
|
|
|
test "all operations are documented" do
|
|
assert_operations_documented()
|
|
end
|
|
|
|
test "all schemas are valid" do
|
|
spec = api_spec()
|
|
schemas = spec.components[:schemas] || %{}
|
|
|
|
Enum.each(schemas, fn {name, schema} ->
|
|
assert schema != nil, "Schema #{name} is nil"
|
|
assert Map.has_key?(schema, :type) || Map.has_key?(schema, :allOf) || Map.has_key?(schema, :oneOf),
|
|
"Schema #{name} has no type"
|
|
end)
|
|
end
|
|
|
|
test "security is properly configured" do
|
|
spec = api_spec()
|
|
|
|
assert spec.components[:security_schemes] != nil
|
|
assert map_size(spec.components[:security_schemes]) > 0
|
|
end
|
|
end
|
|
end
|
|
"""
|
|
|
|
File.write!(file_path, test_content)
|
|
end
|
|
|
|
defp extract_controller_name(%{path: path}) do
|
|
# Extract controller name from path like /api/maps -> Maps
|
|
case String.split(path, "/", parts: 4) do
|
|
["", "api", resource | _] ->
|
|
resource
|
|
|> String.replace("-", "_")
|
|
|> Macro.camelize()
|
|
_ ->
|
|
"Unknown"
|
|
end
|
|
end
|
|
|
|
defp find_operation(spec, operation_id) do
|
|
spec.paths
|
|
|> Enum.flat_map(fn {path, path_item} ->
|
|
path_item
|
|
|> Map.from_struct()
|
|
|> Enum.filter(fn {method, _} -> method in [:get, :post, :put, :patch, :delete] end)
|
|
|> Enum.map(fn {method, op} ->
|
|
Map.merge(op, %{path: path, method: method})
|
|
end)
|
|
end)
|
|
|> Enum.find(&(&1[:operation_id] == operation_id))
|
|
end
|
|
|
|
defp generate_test_cases(operation, spec) do
|
|
%{
|
|
success_cases: generate_success_cases(operation, spec),
|
|
error_cases: generate_error_cases(operation, spec),
|
|
edge_cases: generate_edge_cases(operation, spec)
|
|
}
|
|
end
|
|
|
|
defp generate_success_cases(operation, spec) do
|
|
# Generate test cases for each successful response code
|
|
success_codes =
|
|
operation[:responses]
|
|
|> Map.keys()
|
|
|> Enum.filter(&String.starts_with?(&1, "2"))
|
|
|
|
Enum.map(success_codes, fn code ->
|
|
%{
|
|
status_code: code,
|
|
description: "Successful #{operation[:summary] || "operation"}",
|
|
request: generate_valid_request(operation, spec),
|
|
assertions: [
|
|
"Response matches schema",
|
|
"Required fields are present",
|
|
"Data types are correct"
|
|
]
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp generate_error_cases(operation, spec) do
|
|
error_codes =
|
|
operation[:responses]
|
|
|> Map.keys()
|
|
|> Enum.filter(&String.starts_with?(&1, "4"))
|
|
|
|
Enum.flat_map(error_codes, fn code ->
|
|
case code do
|
|
"400" -> generate_validation_error_cases(operation, spec)
|
|
"401" -> [generate_auth_error_case(operation)]
|
|
"403" -> [generate_forbidden_case(operation)]
|
|
"404" -> [generate_not_found_case(operation)]
|
|
_ -> []
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp generate_edge_cases(operation, _spec) do
|
|
cases = []
|
|
|
|
# Add edge cases based on operation characteristics
|
|
if operation[:has_request_body] do
|
|
cases ++ [
|
|
%{
|
|
description: "Empty request body",
|
|
request: %{body: %{}},
|
|
expected_status: 400
|
|
},
|
|
%{
|
|
description: "Null values for optional fields",
|
|
request: %{body: %{optional_field: nil}},
|
|
expected_status: [200, 201]
|
|
}
|
|
]
|
|
else
|
|
cases
|
|
end
|
|
end
|
|
|
|
defp generate_valid_request(operation, spec) do
|
|
%{
|
|
method: operation.method,
|
|
path: operation.path,
|
|
headers: generate_headers(operation),
|
|
params: generate_valid_params(operation[:parameters] || [], spec),
|
|
body: generate_valid_body(operation, spec)
|
|
}
|
|
end
|
|
|
|
defp generate_invalid_requests(operation, spec) do
|
|
[
|
|
# Missing required parameters
|
|
%{
|
|
type: :missing_required,
|
|
request: %{
|
|
method: operation.method,
|
|
path: operation.path,
|
|
params: %{},
|
|
body: %{}
|
|
}
|
|
},
|
|
# Invalid data types
|
|
%{
|
|
type: :invalid_types,
|
|
request: %{
|
|
method: operation.method,
|
|
path: operation.path,
|
|
params: generate_invalid_type_params(operation[:parameters] || []),
|
|
body: generate_invalid_type_body(operation, spec)
|
|
}
|
|
}
|
|
]
|
|
end
|
|
|
|
defp generate_headers(%{security: nil}), do: %{}
|
|
defp generate_headers(%{security: []}), do: %{}
|
|
defp generate_headers(_), do: %{"authorization" => "Bearer test_token"}
|
|
|
|
defp generate_valid_params(parameters, _spec) do
|
|
Enum.reduce(parameters, %{}, fn param, acc ->
|
|
if param.required do
|
|
Map.put(acc, param.name, generate_param_example(param))
|
|
else
|
|
acc
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp generate_valid_body(%{request_body: nil}, _spec), do: nil
|
|
defp generate_valid_body(_, _spec) do
|
|
%{} # TODO: Generate from schema
|
|
end
|
|
|
|
defp generate_param_example(%{schema: %{type: :string, enum: [first | _]}}), do: first
|
|
defp generate_param_example(%{schema: %{type: :string}}), do: "example_string"
|
|
defp generate_param_example(%{schema: %{type: :integer}}), do: 42
|
|
defp generate_param_example(%{schema: %{type: :boolean}}), do: true
|
|
defp generate_param_example(_), do: "example"
|
|
|
|
defp generate_validation_error_cases(operation, spec) do
|
|
cases = []
|
|
|
|
# Invalid parameter cases
|
|
if operation[:parameters] && length(operation[:parameters]) > 0 do
|
|
cases ++ [%{
|
|
description: "Invalid parameter format",
|
|
request: generate_valid_request(operation, spec) |> put_in([:params, :invalid], "bad_value"),
|
|
expected_status: 400
|
|
}]
|
|
else
|
|
cases
|
|
end
|
|
end
|
|
|
|
defp generate_auth_error_case(operation) do
|
|
%{
|
|
description: "Missing authentication",
|
|
request: %{
|
|
method: operation.method,
|
|
path: operation.path,
|
|
headers: %{}
|
|
},
|
|
expected_status: 401
|
|
}
|
|
end
|
|
|
|
defp generate_forbidden_case(operation) do
|
|
%{
|
|
description: "Insufficient permissions",
|
|
request: %{
|
|
method: operation.method,
|
|
path: operation.path,
|
|
headers: %{"authorization" => "Bearer low_privilege_token"}
|
|
},
|
|
expected_status: 403
|
|
}
|
|
end
|
|
|
|
defp generate_not_found_case(operation) do
|
|
%{
|
|
description: "Resource not found",
|
|
request: %{
|
|
method: operation.method,
|
|
path: String.replace(operation.path, "{id}", "nonexistent_id"),
|
|
headers: generate_headers(operation)
|
|
},
|
|
expected_status: 404
|
|
}
|
|
end
|
|
|
|
defp generate_invalid_type_params(parameters) do
|
|
Enum.reduce(parameters, %{}, fn param, acc ->
|
|
if param[:schema][:type] == :integer do
|
|
Map.put(acc, param.name, "not_a_number")
|
|
else
|
|
acc
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp generate_invalid_type_body(_operation, _spec) do
|
|
%{invalid_field: "invalid_value"}
|
|
end
|
|
end |