Skip to content

Commit decfb1c

Browse files
committed
fix(trogon_commanded): validate command and aggregate identity configuration in CommandHandlerCase
1 parent 96e212d commit decfb1c

File tree

2 files changed

+275
-55
lines changed

2 files changed

+275
-55
lines changed

apps/trogon_commanded/lib/trogon/commanded/test_support/command_handler_case.ex

Lines changed: 101 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ defmodule Trogon.Commanded.TestSupport.CommandHandlerCase do
5050
use ExUnit.CaseTemplate
5151

5252
alias Commanded.Aggregate.Multi
53+
alias Commanded.Middleware.Pipeline
54+
alias Commanded.Middleware.ExtractAggregateIdentity
5355
alias Trogon.Commanded.TestSupport.CommandHandlerCase
5456

5557
using opts do
@@ -106,61 +108,38 @@ defmodule Trogon.Commanded.TestSupport.CommandHandlerCase do
106108
end
107109
end
108110

109-
def assert_events(
110-
initial_events,
111-
command,
112-
expected_events,
113-
aggregate_module,
114-
command_handler_module
115-
) do
116-
assert {:ok, _state, events} =
117-
aggregate_run(
118-
aggregate_module,
119-
command_handler_module,
120-
initial_events,
121-
command
122-
)
123-
124-
actual_events = List.wrap(events)
125-
expected_events = List.wrap(expected_events)
126-
127-
assert actual_events == expected_events
128-
end
129-
130-
def assert_state(
131-
initial_events,
132-
command,
133-
expected_state,
134-
aggregate_module,
135-
command_handler_module
136-
) do
137-
assert {:ok, state, _events} =
138-
aggregate_run(
139-
aggregate_module,
140-
command_handler_module,
141-
initial_events,
142-
command
143-
)
144-
145-
assert state == expected_state
146-
end
147-
148-
def assert_error(
149-
initial_events,
150-
command,
151-
expected_error,
152-
aggregate_module,
153-
command_handler_module
154-
) do
155-
assert {:error, reason} =
156-
aggregate_run(
157-
aggregate_module,
158-
command_handler_module,
159-
initial_events,
160-
command
161-
)
162-
163-
assert reason == expected_error
111+
def assert_events(initial_events, command, expected_events, aggregate_module, command_handler_module) do
112+
run_and_assert(initial_events, command, aggregate_module, command_handler_module, fn
113+
{:ok, _state, events} ->
114+
assert List.wrap(events) == List.wrap(expected_events)
115+
116+
{:error, reason} ->
117+
flunk("Expected success but got error: #{inspect(reason)}")
118+
end)
119+
end
120+
121+
def assert_state(initial_events, command, expected_state, aggregate_module, command_handler_module) do
122+
run_and_assert(initial_events, command, aggregate_module, command_handler_module, fn
123+
{:ok, state, _events} ->
124+
assert state == expected_state
125+
126+
{:error, reason} ->
127+
flunk("Expected success but got error: #{inspect(reason)}")
128+
end)
129+
end
130+
131+
def assert_error(initial_events, command, expected_error, aggregate_module, command_handler_module) do
132+
run_and_assert(initial_events, command, aggregate_module, command_handler_module, fn
133+
{:error, reason} -> assert reason == expected_error
134+
{:ok, _state, _events} -> flunk("Expected error #{inspect(expected_error)}, but command succeeded")
135+
end)
136+
end
137+
138+
defp run_and_assert(initial_events, command, aggregate_module, command_handler_module, assertion_fn) do
139+
assert is_list(initial_events), "Initial events must be a list of events"
140+
validate_identity_configuration(command, aggregate_module)
141+
result = aggregate_run(aggregate_module, command_handler_module, initial_events, command)
142+
assertion_fn.(result)
164143
end
165144

166145
defp aggregate_run(aggregate_module, command_handler_module, initial_events, command) do
@@ -238,4 +217,71 @@ defmodule Trogon.Commanded.TestSupport.CommandHandlerCase do
238217
|> List.wrap()
239218
|> Enum.reduce(state, &evolver.(&2, &1))
240219
end
220+
221+
defp validate_identity_configuration(command, aggregate_module) do
222+
command_module = command.__struct__
223+
224+
command_has_identity? =
225+
function_exported?(command_module, :aggregate_identifier, 0) and
226+
function_exported?(command_module, :identity_prefix, 0)
227+
228+
aggregate_has_identity? =
229+
function_exported?(aggregate_module, :identifier, 0) and
230+
function_exported?(aggregate_module, :identity_prefix, 0)
231+
232+
if command_has_identity? and aggregate_has_identity? do
233+
validate_identifier_match(command_module, aggregate_module)
234+
validate_prefix_match(command_module, aggregate_module)
235+
validate_aggregate_uuid_extraction(command, command_module)
236+
else
237+
:ok
238+
end
239+
end
240+
241+
defp validate_identifier_match(command_module, aggregate_module) do
242+
command_identifier = command_module.aggregate_identifier()
243+
aggregate_identifier = aggregate_module.identifier()
244+
245+
assert command_identifier == aggregate_identifier, """
246+
Identity field mismatch between command and aggregate.
247+
248+
Command #{inspect(command_module)} uses #{inspect(command_identifier)}
249+
Aggregate #{inspect(aggregate_module)} uses #{inspect(aggregate_identifier)}
250+
"""
251+
end
252+
253+
defp validate_prefix_match(command_module, aggregate_module) do
254+
command_prefix = command_module.identity_prefix()
255+
aggregate_prefix = aggregate_module.identity_prefix()
256+
257+
assert command_prefix == aggregate_prefix, """
258+
Identity prefix mismatch between command and aggregate.
259+
260+
Command #{inspect(command_module)} uses #{inspect(command_prefix)}
261+
Aggregate #{inspect(aggregate_module)} uses #{inspect(aggregate_prefix)}
262+
"""
263+
end
264+
265+
defp validate_aggregate_uuid_extraction(command, command_module) do
266+
identifier = command_module.aggregate_identifier()
267+
identity_prefix = command_module.identity_prefix()
268+
269+
pipeline =
270+
%Pipeline{command: command, identity: identifier, identity_prefix: identity_prefix}
271+
|> ExtractAggregateIdentity.before_dispatch()
272+
273+
case pipeline do
274+
%Pipeline{assigns: %{aggregate_uuid: uuid}} when is_binary(uuid) and uuid != "" ->
275+
:ok
276+
277+
_ ->
278+
flunk("""
279+
Failed to extract aggregate UUID from command.
280+
281+
Command: #{inspect(command)}
282+
Identity field: #{inspect(identifier)}
283+
Identity prefix: #{inspect(identity_prefix)}
284+
""")
285+
end
286+
end
241287
end
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
defmodule Trogon.Commanded.TestSupport.CommandHandlerCaseIdentityTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Trogon.Commanded.TestSupport.CommandHandlerCase
5+
6+
alias TestSupport.CommandHandlerCaseFixtures.{
7+
TestAggregate,
8+
TestCommand,
9+
TestEvent
10+
}
11+
12+
defmodule AccountCreated do
13+
use Trogon.Commanded.Event, aggregate_identifier: :uuid
14+
15+
embedded_schema do
16+
field :name, :string
17+
end
18+
end
19+
20+
defmodule CreateAccount do
21+
use Trogon.Commanded.Command,
22+
aggregate_identifier: :uuid,
23+
identity_prefix: "account-"
24+
25+
embedded_schema do
26+
field :name, :string
27+
end
28+
end
29+
30+
defmodule Account do
31+
use Trogon.Commanded.Aggregate,
32+
identifier: :uuid,
33+
identity_prefix: "account-"
34+
35+
embedded_schema do
36+
field :name, :string
37+
end
38+
39+
def apply(aggregate, %AccountCreated{} = event) do
40+
%{aggregate | uuid: event.uuid, name: event.name}
41+
end
42+
43+
def execute(_aggregate, %CreateAccount{} = command) do
44+
%AccountCreated{uuid: command.uuid, name: command.name}
45+
end
46+
end
47+
48+
defmodule MismatchedPrefixCommand do
49+
use Trogon.Commanded.Command,
50+
aggregate_identifier: :uuid,
51+
identity_prefix: "wrong-prefix-"
52+
53+
embedded_schema do
54+
field :name, :string
55+
end
56+
end
57+
58+
defmodule MismatchedFieldCommand do
59+
use Trogon.Commanded.Command,
60+
aggregate_identifier: :account_id,
61+
identity_prefix: "account-"
62+
63+
embedded_schema do
64+
field :name, :string
65+
end
66+
end
67+
68+
describe "identity configuration validation" do
69+
test "passes when command and aggregate identity config match" do
70+
CommandHandlerCase.assert_events(
71+
[],
72+
%CreateAccount{uuid: "test123", name: "Test Account"},
73+
[%AccountCreated{uuid: "test123", name: "Test Account"}],
74+
Account,
75+
nil
76+
)
77+
end
78+
79+
test "passes state assertion when config matches" do
80+
CommandHandlerCase.assert_state(
81+
[],
82+
%CreateAccount{uuid: "test123", name: "Test Account"},
83+
%Account{uuid: "test123", name: "Test Account"},
84+
Account,
85+
nil
86+
)
87+
end
88+
89+
test "fails when identity prefix does not match" do
90+
assert_raise ExUnit.AssertionError, ~r/Identity prefix mismatch/, fn ->
91+
CommandHandlerCase.assert_events(
92+
[],
93+
%MismatchedPrefixCommand{uuid: "test123", name: "Test"},
94+
[],
95+
Account,
96+
nil
97+
)
98+
end
99+
end
100+
101+
test "fails when identity field does not match" do
102+
assert_raise ExUnit.AssertionError, ~r/Identity field mismatch/, fn ->
103+
CommandHandlerCase.assert_events(
104+
[],
105+
%MismatchedFieldCommand{account_id: "test123", name: "Test"},
106+
[],
107+
Account,
108+
nil
109+
)
110+
end
111+
end
112+
113+
test "fails when aggregate UUID cannot be extracted" do
114+
assert_raise ExUnit.AssertionError, ~r/Failed to extract aggregate UUID/, fn ->
115+
CommandHandlerCase.assert_events(
116+
[],
117+
%CreateAccount{uuid: nil, name: "Test"},
118+
[],
119+
Account,
120+
nil
121+
)
122+
end
123+
end
124+
125+
test "validates identity on assert_error path" do
126+
assert_raise ExUnit.AssertionError, ~r/Identity prefix mismatch/, fn ->
127+
CommandHandlerCase.assert_error(
128+
[],
129+
%MismatchedPrefixCommand{uuid: "test123", name: "Test"},
130+
:some_error,
131+
Account,
132+
nil
133+
)
134+
end
135+
end
136+
137+
end
138+
139+
defmodule PlainAggregate do
140+
defstruct [:uuid, :name]
141+
142+
def apply(aggregate, %AccountCreated{} = event) do
143+
%{aggregate | uuid: event.uuid, name: event.name}
144+
end
145+
146+
def execute(_aggregate, %CreateAccount{} = command) do
147+
%AccountCreated{uuid: command.uuid, name: command.name}
148+
end
149+
end
150+
151+
describe "skips validation when only one side has identity config" do
152+
test "trogon command with plain struct aggregate" do
153+
CommandHandlerCase.assert_events(
154+
[],
155+
%CreateAccount{uuid: "test123", name: "Test"},
156+
[%AccountCreated{uuid: "test123", name: "Test"}],
157+
PlainAggregate,
158+
nil
159+
)
160+
end
161+
end
162+
163+
describe "backward compatibility" do
164+
test "neither side has identity config" do
165+
CommandHandlerCase.assert_events(
166+
[],
167+
%TestCommand{id: "simple", action: :create_event},
168+
[%TestEvent{id: "simple", name: "created"}],
169+
TestAggregate,
170+
nil
171+
)
172+
end
173+
end
174+
end

0 commit comments

Comments
 (0)