eventsourcing 9.2.22__py3-none-any.whl → 9.3.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of eventsourcing might be problematic. Click here for more details.

Files changed (144) hide show
  1. eventsourcing/__init__.py +1 -1
  2. eventsourcing/application.py +106 -135
  3. eventsourcing/cipher.py +15 -12
  4. eventsourcing/dispatch.py +31 -91
  5. eventsourcing/domain.py +138 -143
  6. eventsourcing/examples/__init__.py +0 -0
  7. eventsourcing/examples/aggregate1/__init__.py +0 -0
  8. eventsourcing/examples/aggregate1/application.py +27 -0
  9. eventsourcing/examples/aggregate1/domainmodel.py +16 -0
  10. eventsourcing/examples/aggregate1/test_application.py +37 -0
  11. eventsourcing/examples/aggregate2/__init__.py +0 -0
  12. eventsourcing/examples/aggregate2/application.py +27 -0
  13. eventsourcing/examples/aggregate2/domainmodel.py +22 -0
  14. eventsourcing/examples/aggregate2/test_application.py +37 -0
  15. eventsourcing/examples/aggregate3/__init__.py +0 -0
  16. eventsourcing/examples/aggregate3/application.py +27 -0
  17. eventsourcing/examples/aggregate3/domainmodel.py +38 -0
  18. eventsourcing/examples/aggregate3/test_application.py +37 -0
  19. eventsourcing/examples/aggregate4/__init__.py +0 -0
  20. eventsourcing/examples/aggregate4/application.py +27 -0
  21. eventsourcing/examples/aggregate4/domainmodel.py +128 -0
  22. eventsourcing/examples/aggregate4/test_application.py +38 -0
  23. eventsourcing/examples/aggregate5/__init__.py +0 -0
  24. eventsourcing/examples/aggregate5/application.py +27 -0
  25. eventsourcing/examples/aggregate5/domainmodel.py +131 -0
  26. eventsourcing/examples/aggregate5/test_application.py +38 -0
  27. eventsourcing/examples/aggregate6/__init__.py +0 -0
  28. eventsourcing/examples/aggregate6/application.py +30 -0
  29. eventsourcing/examples/aggregate6/domainmodel.py +123 -0
  30. eventsourcing/examples/aggregate6/test_application.py +38 -0
  31. eventsourcing/examples/aggregate6a/__init__.py +0 -0
  32. eventsourcing/examples/aggregate6a/application.py +40 -0
  33. eventsourcing/examples/aggregate6a/domainmodel.py +149 -0
  34. eventsourcing/examples/aggregate6a/test_application.py +45 -0
  35. eventsourcing/examples/aggregate7/__init__.py +0 -0
  36. eventsourcing/examples/aggregate7/application.py +48 -0
  37. eventsourcing/examples/aggregate7/domainmodel.py +144 -0
  38. eventsourcing/examples/aggregate7/persistence.py +57 -0
  39. eventsourcing/examples/aggregate7/test_application.py +38 -0
  40. eventsourcing/examples/aggregate7/test_compression_and_encryption.py +45 -0
  41. eventsourcing/examples/aggregate7/test_snapshotting_intervals.py +67 -0
  42. eventsourcing/examples/aggregate7a/__init__.py +0 -0
  43. eventsourcing/examples/aggregate7a/application.py +56 -0
  44. eventsourcing/examples/aggregate7a/domainmodel.py +170 -0
  45. eventsourcing/examples/aggregate7a/test_application.py +46 -0
  46. eventsourcing/examples/aggregate7a/test_compression_and_encryption.py +45 -0
  47. eventsourcing/examples/aggregate8/__init__.py +0 -0
  48. eventsourcing/examples/aggregate8/application.py +47 -0
  49. eventsourcing/examples/aggregate8/domainmodel.py +65 -0
  50. eventsourcing/examples/aggregate8/persistence.py +57 -0
  51. eventsourcing/examples/aggregate8/test_application.py +37 -0
  52. eventsourcing/examples/aggregate8/test_compression_and_encryption.py +44 -0
  53. eventsourcing/examples/aggregate8/test_snapshotting_intervals.py +38 -0
  54. eventsourcing/examples/bankaccounts/__init__.py +0 -0
  55. eventsourcing/examples/bankaccounts/application.py +70 -0
  56. eventsourcing/examples/bankaccounts/domainmodel.py +56 -0
  57. eventsourcing/examples/bankaccounts/test.py +173 -0
  58. eventsourcing/examples/cargoshipping/__init__.py +0 -0
  59. eventsourcing/examples/cargoshipping/application.py +126 -0
  60. eventsourcing/examples/cargoshipping/domainmodel.py +330 -0
  61. eventsourcing/examples/cargoshipping/interface.py +143 -0
  62. eventsourcing/examples/cargoshipping/test.py +231 -0
  63. eventsourcing/examples/contentmanagement/__init__.py +0 -0
  64. eventsourcing/examples/contentmanagement/application.py +118 -0
  65. eventsourcing/examples/contentmanagement/domainmodel.py +69 -0
  66. eventsourcing/examples/contentmanagement/test.py +180 -0
  67. eventsourcing/examples/contentmanagement/utils.py +26 -0
  68. eventsourcing/examples/contentmanagementsystem/__init__.py +0 -0
  69. eventsourcing/examples/contentmanagementsystem/application.py +54 -0
  70. eventsourcing/examples/contentmanagementsystem/postgres.py +17 -0
  71. eventsourcing/examples/contentmanagementsystem/sqlite.py +17 -0
  72. eventsourcing/examples/contentmanagementsystem/system.py +14 -0
  73. eventsourcing/examples/contentmanagementsystem/test_system.py +174 -0
  74. eventsourcing/examples/searchablecontent/__init__.py +0 -0
  75. eventsourcing/examples/searchablecontent/application.py +45 -0
  76. eventsourcing/examples/searchablecontent/persistence.py +23 -0
  77. eventsourcing/examples/searchablecontent/postgres.py +118 -0
  78. eventsourcing/examples/searchablecontent/sqlite.py +136 -0
  79. eventsourcing/examples/searchablecontent/test_application.py +111 -0
  80. eventsourcing/examples/searchablecontent/test_recorder.py +69 -0
  81. eventsourcing/examples/searchabletimestamps/__init__.py +0 -0
  82. eventsourcing/examples/searchabletimestamps/application.py +32 -0
  83. eventsourcing/examples/searchabletimestamps/persistence.py +20 -0
  84. eventsourcing/examples/searchabletimestamps/postgres.py +110 -0
  85. eventsourcing/examples/searchabletimestamps/sqlite.py +99 -0
  86. eventsourcing/examples/searchabletimestamps/test_searchabletimestamps.py +91 -0
  87. eventsourcing/examples/test_invoice.py +176 -0
  88. eventsourcing/examples/test_parking_lot.py +206 -0
  89. eventsourcing/interface.py +2 -2
  90. eventsourcing/persistence.py +85 -81
  91. eventsourcing/popo.py +30 -31
  92. eventsourcing/postgres.py +361 -578
  93. eventsourcing/sqlite.py +91 -99
  94. eventsourcing/system.py +42 -57
  95. eventsourcing/tests/application.py +20 -32
  96. eventsourcing/tests/application_tests/__init__.py +0 -0
  97. eventsourcing/tests/application_tests/test_application_with_automatic_snapshotting.py +55 -0
  98. eventsourcing/tests/application_tests/test_application_with_popo.py +22 -0
  99. eventsourcing/tests/application_tests/test_application_with_postgres.py +75 -0
  100. eventsourcing/tests/application_tests/test_application_with_sqlite.py +72 -0
  101. eventsourcing/tests/application_tests/test_cache.py +134 -0
  102. eventsourcing/tests/application_tests/test_event_sourced_log.py +162 -0
  103. eventsourcing/tests/application_tests/test_notificationlog.py +232 -0
  104. eventsourcing/tests/application_tests/test_notificationlogreader.py +126 -0
  105. eventsourcing/tests/application_tests/test_processapplication.py +110 -0
  106. eventsourcing/tests/application_tests/test_processingpolicy.py +109 -0
  107. eventsourcing/tests/application_tests/test_repository.py +504 -0
  108. eventsourcing/tests/application_tests/test_snapshotting.py +68 -0
  109. eventsourcing/tests/application_tests/test_upcasting.py +459 -0
  110. eventsourcing/tests/docs_tests/__init__.py +0 -0
  111. eventsourcing/tests/docs_tests/test_docs.py +293 -0
  112. eventsourcing/tests/domain.py +1 -1
  113. eventsourcing/tests/domain_tests/__init__.py +0 -0
  114. eventsourcing/tests/domain_tests/test_aggregate.py +1159 -0
  115. eventsourcing/tests/domain_tests/test_aggregate_decorators.py +1604 -0
  116. eventsourcing/tests/domain_tests/test_domainevent.py +80 -0
  117. eventsourcing/tests/interface_tests/__init__.py +0 -0
  118. eventsourcing/tests/interface_tests/test_remotenotificationlog.py +258 -0
  119. eventsourcing/tests/persistence.py +49 -50
  120. eventsourcing/tests/persistence_tests/__init__.py +0 -0
  121. eventsourcing/tests/persistence_tests/test_aes.py +93 -0
  122. eventsourcing/tests/persistence_tests/test_connection_pool.py +722 -0
  123. eventsourcing/tests/persistence_tests/test_eventstore.py +72 -0
  124. eventsourcing/tests/persistence_tests/test_infrastructure_factory.py +21 -0
  125. eventsourcing/tests/persistence_tests/test_mapper.py +113 -0
  126. eventsourcing/tests/persistence_tests/test_noninterleaving_notification_ids.py +69 -0
  127. eventsourcing/tests/persistence_tests/test_popo.py +124 -0
  128. eventsourcing/tests/persistence_tests/test_postgres.py +1121 -0
  129. eventsourcing/tests/persistence_tests/test_sqlite.py +348 -0
  130. eventsourcing/tests/persistence_tests/test_transcoder.py +44 -0
  131. eventsourcing/tests/postgres_utils.py +7 -7
  132. eventsourcing/tests/system_tests/__init__.py +0 -0
  133. eventsourcing/tests/system_tests/test_runner.py +935 -0
  134. eventsourcing/tests/system_tests/test_system.py +287 -0
  135. eventsourcing/tests/utils_tests/__init__.py +0 -0
  136. eventsourcing/tests/utils_tests/test_utils.py +226 -0
  137. eventsourcing/utils.py +47 -50
  138. {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/METADATA +28 -80
  139. eventsourcing-9.3.0a1.dist-info/RECORD +144 -0
  140. {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/WHEEL +1 -2
  141. eventsourcing-9.2.22.dist-info/AUTHORS +0 -10
  142. eventsourcing-9.2.22.dist-info/RECORD +0 -25
  143. eventsourcing-9.2.22.dist-info/top_level.txt +0 -1
  144. {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/LICENSE +0 -0
@@ -0,0 +1,1159 @@
1
+ import inspect
2
+ from dataclasses import _DataclassParams, dataclass
3
+ from datetime import datetime
4
+ from decimal import Decimal
5
+ from unittest.case import TestCase
6
+ from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
7
+
8
+ from eventsourcing.domain import (
9
+ Aggregate,
10
+ AggregateCreated,
11
+ AggregateEvent,
12
+ OriginatorIDError,
13
+ OriginatorVersionError,
14
+ TAggregate,
15
+ )
16
+ from eventsourcing.tests.domain import (
17
+ AccountClosedError,
18
+ BankAccount,
19
+ InsufficientFundsError,
20
+ )
21
+ from eventsourcing.utils import get_method_name
22
+
23
+
24
+ class TestMetaAggregate(TestCase):
25
+ def test_aggregate_class_has_a_created_event_class(self):
26
+ self.assertTrue(hasattr(Aggregate, "_created_event_class"))
27
+ self.assertTrue(issubclass(Aggregate._created_event_class, AggregateCreated))
28
+ self.assertEqual(Aggregate._created_event_class, Aggregate.Created)
29
+
30
+ def test_aggregate_subclass_is_a_dataclass_iff_decorated_or_has_annotations(self):
31
+ # No dataclass decorator, no annotations.
32
+ class MyAggregate(Aggregate):
33
+ pass
34
+
35
+ self.assertFalse("__dataclass_params__" in MyAggregate.__dict__)
36
+
37
+ # Has a dataclass decorator (helps IDE know what's going on with annotations).
38
+ @dataclass
39
+ class MyAggregate(Aggregate):
40
+ pass
41
+
42
+ self.assertTrue("__dataclass_params__" in MyAggregate.__dict__)
43
+ self.assertIsInstance(MyAggregate.__dataclass_params__, _DataclassParams)
44
+ self.assertFalse(MyAggregate.__dataclass_params__.frozen)
45
+
46
+ # Has annotations but no decorator.
47
+ @dataclass
48
+ class MyAggregate(Aggregate):
49
+ a: int
50
+
51
+ self.assertTrue("__dataclass_params__" in MyAggregate.__dict__)
52
+ self.assertIsInstance(MyAggregate.__dataclass_params__, _DataclassParams)
53
+ self.assertFalse(MyAggregate.__dataclass_params__.frozen)
54
+
55
+ def test_aggregate_subclass_gets_a_default_created_event_class(self):
56
+ class MyAggregate(Aggregate):
57
+ pass
58
+
59
+ self.assertTrue(hasattr(MyAggregate, "_created_event_class"))
60
+ self.assertTrue(issubclass(MyAggregate._created_event_class, AggregateCreated))
61
+ self.assertEqual(MyAggregate._created_event_class, MyAggregate.Created)
62
+
63
+ def test_aggregate_subclass_has_a_custom_created_event_class(self):
64
+ class MyAggregate(Aggregate):
65
+ class Started(AggregateCreated):
66
+ pass
67
+
68
+ self.assertTrue(hasattr(MyAggregate, "_created_event_class"))
69
+ self.assertTrue(issubclass(MyAggregate._created_event_class, AggregateCreated))
70
+ self.assertEqual(MyAggregate._created_event_class, MyAggregate.Started)
71
+
72
+ def test_aggregate_subclass_has_a_custom_created_event_class_name(self):
73
+ @dataclass
74
+ class MyAggregate(Aggregate, created_event_name="Started"):
75
+ pass
76
+
77
+ a = MyAggregate()
78
+ self.assertEqual(type(a.pending_events[0]).__name__, "Started")
79
+
80
+ self.assertTrue(hasattr(MyAggregate, "_created_event_class"))
81
+ created_event_cls = MyAggregate._created_event_class
82
+ self.assertEqual(created_event_cls.__name__, "Started")
83
+ self.assertTrue(created_event_cls.__qualname__.endswith("MyAggregate.Started"))
84
+ self.assertTrue(issubclass(created_event_cls, AggregateCreated))
85
+ self.assertEqual(created_event_cls, MyAggregate.Started)
86
+
87
+ def test_can_define_initial_version_number(self):
88
+ class MyAggregate1(Aggregate):
89
+ INITIAL_VERSION = 0
90
+
91
+ a = MyAggregate1()
92
+ self.assertEqual(a.version, 0)
93
+
94
+ class MyAggregate2(Aggregate):
95
+ pass
96
+
97
+ a = MyAggregate2()
98
+ self.assertEqual(a.version, 1)
99
+
100
+ class MyAggregate3(Aggregate):
101
+ INITIAL_VERSION = 2
102
+
103
+ a = MyAggregate3()
104
+ self.assertEqual(a.version, 2)
105
+
106
+
107
+ class TestAggregateCreation(TestCase):
108
+ def test_call_class_method_create(self):
109
+ # Check the _create() method creates a new aggregate.
110
+ before_created = Aggregate.Event.create_timestamp()
111
+ uuid = uuid4()
112
+ a = Aggregate._create(
113
+ event_class=AggregateCreated,
114
+ id=uuid,
115
+ )
116
+ after_created = Aggregate.Event.create_timestamp()
117
+ self.assertIsInstance(a, Aggregate)
118
+ self.assertEqual(a.id, uuid)
119
+ self.assertEqual(a.version, 1)
120
+ self.assertEqual(a.created_on, a.modified_on)
121
+ self.assertGreater(a.created_on, before_created)
122
+ self.assertGreater(after_created, a.created_on)
123
+
124
+ def test_raises_when_create_args_mismatch_created_event(self):
125
+ class BrokenAggregate(Aggregate):
126
+ @classmethod
127
+ def create(cls, name):
128
+ return cls._create(event_class=cls.Created, id=uuid4(), name=name)
129
+
130
+ with self.assertRaises(TypeError) as cm:
131
+ BrokenAggregate.create("name")
132
+
133
+ method_name = get_method_name(BrokenAggregate.Created.__init__)
134
+
135
+ self.assertEqual(
136
+ "Unable to construct 'Created' event: "
137
+ f"{method_name}() got an unexpected keyword argument 'name'",
138
+ cm.exception.args[0],
139
+ )
140
+
141
+ def test_call_base_class(self):
142
+ before_created = Aggregate.Event.create_timestamp()
143
+ a = Aggregate()
144
+ after_created = Aggregate.Event.create_timestamp()
145
+ self.assertIsInstance(a, Aggregate)
146
+ self.assertIsInstance(a.id, UUID)
147
+ self.assertIsInstance(a.version, int)
148
+ self.assertEqual(a.version, 1)
149
+ self.assertIsInstance(a.created_on, datetime)
150
+ self.assertIsInstance(a.modified_on, datetime)
151
+ self.assertEqual(a.created_on, a.modified_on)
152
+ self.assertGreater(a.created_on, before_created)
153
+ self.assertGreater(after_created, a.created_on)
154
+
155
+ events = a.collect_events()
156
+ self.assertIsInstance(events[0], AggregateCreated)
157
+ self.assertEqual("Aggregate.Created", type(events[0]).__qualname__)
158
+
159
+ def test_call_subclass_with_no_init(self):
160
+ qualname = type(self).__qualname__
161
+ prefix = f"{qualname}.test_call_subclass_with_no_init.<locals>."
162
+
163
+ class MyAggregate1(Aggregate):
164
+ pass
165
+
166
+ a = MyAggregate1()
167
+ self.assertIsInstance(a.id, UUID)
168
+ self.assertIsInstance(a.version, int)
169
+ self.assertEqual(a.version, 1)
170
+ self.assertIsInstance(a.created_on, datetime)
171
+ self.assertIsInstance(a.modified_on, datetime)
172
+
173
+ events = a.collect_events()
174
+ self.assertEqual(len(events), 1)
175
+ self.assertIsInstance(events[0], AggregateCreated)
176
+ self.assertEqual(f"{prefix}MyAggregate1.Created", type(events[0]).__qualname__)
177
+
178
+ # Do it again using @dataclass
179
+ @dataclass # ...this just makes the code completion work in the IDE.
180
+ class MyAggregate2(Aggregate):
181
+ pass
182
+
183
+ # Check the init method takes no args (except "self").
184
+ init_params = inspect.signature(MyAggregate2.__init__).parameters
185
+ self.assertEqual(len(init_params), 1)
186
+ self.assertEqual(next(iter(init_params)), "self")
187
+
188
+ #
189
+ # Do it again with custom "created" event.
190
+ @dataclass
191
+ class MyAggregate3(Aggregate):
192
+ class Started(AggregateCreated):
193
+ pass
194
+
195
+ a = MyAggregate3()
196
+ self.assertIsInstance(a.id, UUID)
197
+ self.assertIsInstance(a.version, int)
198
+ self.assertIsInstance(a.created_on, datetime)
199
+ self.assertIsInstance(a.modified_on, datetime)
200
+
201
+ events = a.collect_events()
202
+ self.assertEqual(len(events), 1)
203
+ self.assertIsInstance(events[0], AggregateCreated)
204
+ self.assertEqual(f"{prefix}MyAggregate3.Started", type(events[0]).__qualname__)
205
+
206
+ def test_init_no_args(self):
207
+ qualname = type(self).__qualname__
208
+ prefix = f"{qualname}.test_init_no_args.<locals>."
209
+
210
+ class MyAggregate1(Aggregate):
211
+ def __init__(self):
212
+ pass
213
+
214
+ a = MyAggregate1()
215
+ self.assertIsInstance(a.id, UUID)
216
+ self.assertIsInstance(a.version, int)
217
+ self.assertIsInstance(a.created_on, datetime)
218
+ self.assertIsInstance(a.modified_on, datetime)
219
+
220
+ events = a.collect_events()
221
+ self.assertEqual(len(events), 1)
222
+ self.assertIsInstance(events[0], AggregateCreated)
223
+ self.assertEqual(f"{prefix}MyAggregate1.Created", type(events[0]).__qualname__)
224
+
225
+ #
226
+ # Do it again using @dataclass (makes no difference)...
227
+ @dataclass # ...this just makes the code completion work in the IDE.
228
+ class MyAggregate2(Aggregate):
229
+ def __init__(self):
230
+ pass
231
+
232
+ # Check the init method takes no args (except "self").
233
+ init_params = inspect.signature(MyAggregate2.__init__).parameters
234
+ self.assertEqual(len(init_params), 1)
235
+ self.assertEqual(next(iter(init_params)), "self")
236
+
237
+ #
238
+ # Do it again with custom "created" event.
239
+ @dataclass
240
+ class MyAggregate3(Aggregate):
241
+ class Started(AggregateCreated):
242
+ pass
243
+
244
+ a = MyAggregate3()
245
+ self.assertIsInstance(a.id, UUID)
246
+ self.assertIsInstance(a.version, int)
247
+ self.assertIsInstance(a.created_on, datetime)
248
+ self.assertIsInstance(a.modified_on, datetime)
249
+
250
+ events = a.collect_events()
251
+ self.assertEqual(len(events), 1)
252
+ self.assertIsInstance(events[0], AggregateCreated)
253
+ self.assertEqual(f"{prefix}MyAggregate3.Started", type(events[0]).__qualname__)
254
+
255
+ def test_raises_when_init_with_no_args_called_with_args(self):
256
+ # First, with a normal dataclass, to document the errors.
257
+ @dataclass
258
+ class Data(Aggregate):
259
+ pass
260
+
261
+ # Second, with an aggregate class, to replicate same errors.
262
+ @dataclass
263
+ class MyAgg(Aggregate):
264
+ pass
265
+
266
+ def assert_raises(cls):
267
+ method_name = get_method_name(cls.__init__)
268
+
269
+ with self.assertRaises(TypeError) as cm:
270
+ cls(0)
271
+
272
+ self.assertEqual(
273
+ cm.exception.args[0],
274
+ f"{method_name}() takes 1 positional argument but 2 were given",
275
+ )
276
+
277
+ with self.assertRaises(TypeError) as cm:
278
+ cls(value=0)
279
+
280
+ self.assertEqual(
281
+ cm.exception.args[0],
282
+ f"{method_name}() got an unexpected keyword argument 'value'",
283
+ )
284
+
285
+ assert_raises(Data)
286
+ assert_raises(MyAgg)
287
+
288
+ def test_init_defined_with_positional_or_keyword_arg(self):
289
+ class MyAgg(Aggregate):
290
+ def __init__(self, value):
291
+ self.value = value
292
+
293
+ a = MyAgg(1)
294
+ self.assertIsInstance(a, MyAgg)
295
+ self.assertEqual(a.value, 1)
296
+ self.assertIsInstance(a, Aggregate)
297
+ self.assertEqual(len(a.pending_events), 1)
298
+
299
+ a = MyAgg(value=1)
300
+ self.assertIsInstance(a, MyAgg)
301
+ self.assertEqual(a.value, 1)
302
+ self.assertIsInstance(a, Aggregate)
303
+ self.assertEqual(len(a.pending_events), 1)
304
+
305
+ def test_init_defined_with_default_keyword_arg(self):
306
+ class MyAgg(Aggregate):
307
+ def __init__(self, value=0):
308
+ self.value = value
309
+
310
+ a = MyAgg()
311
+ self.assertIsInstance(a, MyAgg)
312
+ self.assertEqual(a.value, 0)
313
+ self.assertIsInstance(a, Aggregate)
314
+ self.assertEqual(len(a.pending_events), 1)
315
+
316
+ def test_init_with_default_keyword_arg_required_positional_and_keyword_only(self):
317
+ class MyAgg(Aggregate):
318
+ def __init__(self, a, b=0, *, c):
319
+ self.a = a
320
+ self.b = b
321
+ self.c = c
322
+
323
+ x = MyAgg(1, c=2)
324
+ self.assertEqual(x.a, 1)
325
+ self.assertEqual(x.b, 0)
326
+ self.assertEqual(x.c, 2)
327
+
328
+ def test_raises_when_init_missing_1_required_positional_arg(self):
329
+ class MyAgg(Aggregate):
330
+ def __init__(self, value):
331
+ self.value = value
332
+
333
+ with self.assertRaises(TypeError) as cm:
334
+ MyAgg()
335
+
336
+ self.assertEqual(
337
+ cm.exception.args[0],
338
+ f"{get_method_name(MyAgg.__init__)}() missing 1 required "
339
+ "positional argument: 'value'",
340
+ )
341
+
342
+ def test_raises_when_init_missing_1_required_keyword_only_arg(self):
343
+ class MyAgg(Aggregate):
344
+ def __init__(self, *, value):
345
+ self.value = value
346
+
347
+ with self.assertRaises(TypeError) as cm:
348
+ MyAgg()
349
+
350
+ self.assertEqual(
351
+ cm.exception.args[0],
352
+ f"{get_method_name(MyAgg.__init__)}() missing 1 required "
353
+ "keyword-only argument: 'value'",
354
+ )
355
+
356
+ def test_raises_when_init_missing_required_positional_and_keyword_only_arg(self):
357
+ class MyAgg(Aggregate):
358
+ def __init__(self, a, *, b):
359
+ pass
360
+
361
+ with self.assertRaises(TypeError) as cm:
362
+ MyAgg()
363
+
364
+ method_name = get_method_name(MyAgg.__init__)
365
+
366
+ self.assertEqual(
367
+ cm.exception.args[0],
368
+ f"{method_name}() missing 1 required positional argument: 'a'",
369
+ )
370
+
371
+ class MyAgg(Aggregate):
372
+ def __init__(self, a, b=0, *, c):
373
+ self.a = a
374
+ self.b = b
375
+ self.c = c
376
+
377
+ with self.assertRaises(TypeError) as cm:
378
+ MyAgg(c=2)
379
+
380
+ self.assertEqual(
381
+ cm.exception.args[0],
382
+ f"{method_name}() missing 1 required positional argument: 'a'",
383
+ )
384
+
385
+ def test_raises_when_init_missing_2_required_positional_args(self):
386
+ class MyAgg(Aggregate):
387
+ def __init__(self, a, b, *, c):
388
+ pass
389
+
390
+ with self.assertRaises(TypeError) as cm:
391
+ MyAgg()
392
+
393
+ method_name = get_method_name(MyAgg.__init__)
394
+
395
+ self.assertEqual(
396
+ cm.exception.args[0],
397
+ f"{method_name}() missing 2 required positional arguments: 'a' and 'b'",
398
+ )
399
+
400
+ def test_raises_when_init_gets_unexpected_keyword_argument(self):
401
+ class MyAgg(Aggregate):
402
+ def __init__(self, a=1):
403
+ pass
404
+
405
+ with self.assertRaises(TypeError) as cm:
406
+ MyAgg(b=1)
407
+
408
+ method_name = get_method_name(MyAgg.__init__)
409
+
410
+ self.assertEqual(
411
+ cm.exception.args[0],
412
+ f"{method_name}() got an unexpected keyword argument 'b'",
413
+ )
414
+
415
+ with self.assertRaises(TypeError) as cm:
416
+ MyAgg(c=1)
417
+
418
+ self.assertEqual(
419
+ cm.exception.args[0],
420
+ f"{method_name}() got an unexpected keyword argument 'c'",
421
+ )
422
+
423
+ with self.assertRaises(TypeError) as cm:
424
+ MyAgg(b=1, c=1)
425
+
426
+ self.assertEqual(
427
+ cm.exception.args[0],
428
+ f"{method_name}() got an unexpected keyword argument 'b'",
429
+ )
430
+
431
+ def test_init_defined_as_dataclass_no_default(self):
432
+ class MyAgg(Aggregate):
433
+ value: int
434
+
435
+ a = MyAgg(1)
436
+ self.assertIsInstance(a, MyAgg)
437
+ self.assertEqual(a.value, 1)
438
+ self.assertIsInstance(a, Aggregate)
439
+ self.assertEqual(len(a.pending_events), 1)
440
+
441
+ a = MyAgg(value=1)
442
+ self.assertIsInstance(a, MyAgg)
443
+ self.assertEqual(a.value, 1)
444
+ self.assertIsInstance(a, Aggregate)
445
+ self.assertEqual(len(a.pending_events), 1)
446
+
447
+ def test_init_defined_as_dataclass_with_default(self):
448
+ class MyAgg(Aggregate):
449
+ value: int = 0
450
+
451
+ a = MyAgg(1)
452
+ self.assertIsInstance(a, MyAgg)
453
+ self.assertEqual(a.value, 1)
454
+ self.assertIsInstance(a, Aggregate)
455
+ self.assertEqual(len(a.pending_events), 1)
456
+
457
+ a = MyAgg(value=1)
458
+ self.assertIsInstance(a, MyAgg)
459
+ self.assertEqual(a.value, 1)
460
+ self.assertIsInstance(a, Aggregate)
461
+ self.assertEqual(len(a.pending_events), 1)
462
+
463
+ a = MyAgg()
464
+ self.assertIsInstance(a, MyAgg)
465
+ self.assertEqual(a.value, 0)
466
+ self.assertIsInstance(a, Aggregate)
467
+ self.assertEqual(len(a.pending_events), 1)
468
+
469
+ with self.assertRaises(TypeError) as cm:
470
+ MyAgg(wrong=1)
471
+
472
+ method_name = get_method_name(MyAgg.__init__)
473
+
474
+ self.assertEqual(
475
+ f"{method_name}() got an unexpected keyword argument 'wrong'",
476
+ cm.exception.args[0],
477
+ )
478
+
479
+ def test_init_defined_as_dataclass_mixture_of_nondefault_and_default_values(self):
480
+ @dataclass
481
+ class MyAgg(Aggregate):
482
+ a: int
483
+ b: int
484
+ c: int = 1
485
+ d: int = 2
486
+
487
+ # This to check aggregate performs the same behaviour.
488
+ @dataclass
489
+ class Data:
490
+ a: int
491
+ b: int
492
+ c: int = 1
493
+ d: int = 2
494
+
495
+ def test_init(cls):
496
+ obj = cls(b=1, a=2)
497
+ self.assertEqual(obj.a, 2)
498
+ self.assertEqual(obj.b, 1)
499
+ self.assertEqual(obj.c, 1)
500
+ self.assertEqual(obj.d, 2)
501
+
502
+ obj = cls(1, 2, 3, 4)
503
+ self.assertEqual(obj.a, 1)
504
+ self.assertEqual(obj.b, 2)
505
+ self.assertEqual(obj.c, 3)
506
+ self.assertEqual(obj.d, 4)
507
+
508
+ with self.assertRaises(TypeError) as cm:
509
+ obj = cls(1, 2, 3, c=4)
510
+ self.assertEqual(obj.a, 1)
511
+ self.assertEqual(obj.b, 2)
512
+ self.assertEqual(obj.c, 4)
513
+ self.assertEqual(obj.d, 3)
514
+
515
+ method_name = get_method_name(cls.__init__)
516
+
517
+ self.assertEqual(
518
+ f"{method_name}() got multiple values for argument 'c'",
519
+ cm.exception.args[0],
520
+ )
521
+
522
+ with self.assertRaises(TypeError) as cm:
523
+ obj = cls(1, a=2, d=3, c=4)
524
+ self.assertEqual(obj.a, 2)
525
+ self.assertEqual(obj.b, 1)
526
+ self.assertEqual(obj.c, 4)
527
+ self.assertEqual(obj.d, 3)
528
+
529
+ self.assertEqual(
530
+ f"{method_name}() got multiple values for argument 'a'",
531
+ cm.exception.args[0],
532
+ )
533
+
534
+ test_init(Data)
535
+ test_init(MyAgg)
536
+
537
+ def test_raises_when_init_has_variable_positional_params(self):
538
+ with self.assertRaises(TypeError) as cm:
539
+
540
+ class _(Aggregate): # noqa: N801
541
+ def __init__(self, *values):
542
+ pass
543
+
544
+ self.assertEqual(
545
+ cm.exception.args[0], "*values not supported by decorator on __init__()"
546
+ )
547
+
548
+ def test_raises_when_init_has_variable_keyword_params(self):
549
+ with self.assertRaises(TypeError) as cm:
550
+
551
+ class _(Aggregate): # noqa: N801
552
+ def __init__(self, **values):
553
+ pass
554
+
555
+ self.assertEqual(
556
+ cm.exception.args[0], "**values not supported by decorator on __init__()"
557
+ )
558
+
559
+ def test_define_custom_create_id_as_uuid5(self):
560
+ class MyAggregate1(Aggregate):
561
+ def __init__(self, name):
562
+ self.name = name
563
+
564
+ @classmethod
565
+ def create_id(cls, name):
566
+ return uuid5(NAMESPACE_URL, f"/names/{name}")
567
+
568
+ a = MyAggregate1("name")
569
+ self.assertEqual(a.name, "name")
570
+ self.assertEqual(a.id, MyAggregate1.create_id("name"))
571
+
572
+ # Do it again with method defined as staticmethod.
573
+ @dataclass
574
+ class MyAggregate2(Aggregate):
575
+ name: str
576
+
577
+ @staticmethod
578
+ def create_id(name):
579
+ return uuid5(NAMESPACE_URL, f"/names/{name}")
580
+
581
+ a = MyAggregate2("name")
582
+ self.assertEqual(a.name, "name")
583
+ self.assertEqual(a.id, MyAggregate2.create_id("name"))
584
+
585
+ def test_raises_type_error_if_create_id_not_staticmethod_or_classmethod(self):
586
+ with self.assertRaises(TypeError):
587
+
588
+ class MyAggregate(Aggregate):
589
+ def create_id(self, myarg):
590
+ pass
591
+
592
+ def test_raises_type_error_if_created_event_class_not_aggregate_created(self):
593
+ with self.assertRaises(TypeError):
594
+
595
+ class MyAggregate(Aggregate):
596
+ _created_event_class = Aggregate.Event
597
+
598
+ def test_refuse_implicit_choice_of_alternative_created_events(self):
599
+ # In case aggregates were created with old Created event,
600
+ # there may need to be several defined. Then, when calling
601
+ # aggregate class, require explicit statement of which to use.
602
+
603
+ # Don't specify created event class.
604
+ class MyAggregate1(Aggregate):
605
+ class Started(AggregateCreated):
606
+ pass
607
+
608
+ class Opened(AggregateCreated):
609
+ pass
610
+
611
+ # This is okay.
612
+ MyAggregate1._create(event_class=MyAggregate1.Started)
613
+ MyAggregate1._create(event_class=MyAggregate1.Opened)
614
+
615
+ with self.assertRaises(TypeError) as cm:
616
+ # This is not okay.
617
+ MyAggregate1()
618
+
619
+ self.assertTrue(
620
+ cm.exception.args[0].startswith(
621
+ "Can't decide which of many "
622
+ '"created" event classes to '
623
+ "use: 'Started', 'Opened'"
624
+ )
625
+ )
626
+
627
+ # Specify created event class using _created_event_class.
628
+ class MyAggregate2(Aggregate):
629
+ class Started(AggregateCreated):
630
+ pass
631
+
632
+ class Opened(AggregateCreated):
633
+ pass
634
+
635
+ _created_event_class = Started
636
+
637
+ # Call class, and expect Started event will be used.
638
+ a = MyAggregate2()
639
+ events = a.collect_events()
640
+ self.assertIsInstance(events[0], MyAggregate2.Started, type(events[0]))
641
+
642
+ # Specify created event class using created_event_name.
643
+ class MyAggregate3(Aggregate, created_event_name="Started"):
644
+ class Started(AggregateCreated):
645
+ pass
646
+
647
+ class Opened(AggregateCreated):
648
+ pass
649
+
650
+ # Call class, and expect Started event will be used.
651
+ a = MyAggregate3()
652
+ events = a.collect_events()
653
+ self.assertIsInstance(events[0], MyAggregate3.Started)
654
+
655
+ def test_refuse_implicit_choice_of_alternative_created_events_on_subclass(self):
656
+ # In case aggregates were created with old Created event,
657
+ # there may need to be several defined. Then, when calling
658
+ # aggregate class, require explicit statement of which to use.
659
+ class MyBaseAggregate(Aggregate, created_event_name="Opened"):
660
+ class Started(AggregateCreated):
661
+ pass
662
+
663
+ class Opened(AggregateCreated):
664
+ pass
665
+
666
+ class MyAggregate1(MyBaseAggregate):
667
+ class Started(AggregateCreated):
668
+ pass
669
+
670
+ class Opened(AggregateCreated):
671
+ pass
672
+
673
+ # This is okay.
674
+ MyAggregate1._create(event_class=MyAggregate1.Started)
675
+ MyAggregate1._create(event_class=MyAggregate1.Opened)
676
+
677
+ with self.assertRaises(TypeError) as cm:
678
+ MyAggregate1() # This is not okay.
679
+
680
+ self.assertTrue(
681
+ cm.exception.args[0].startswith(
682
+ "Can't decide which of many "
683
+ '"created" event classes to '
684
+ "use: 'Started', 'Opened'"
685
+ )
686
+ )
687
+
688
+ # Specify created event class using _created_event_class.
689
+ class MyAggregate2(MyBaseAggregate):
690
+ class Started(AggregateCreated):
691
+ pass
692
+
693
+ class Opened(AggregateCreated):
694
+ pass
695
+
696
+ _created_event_class = Started
697
+
698
+ # Call class, and expect Started event will be used.
699
+ a = MyAggregate2()
700
+ events = a.collect_events()
701
+ self.assertIsInstance(events[0], MyAggregate2.Started)
702
+
703
+ def test_uses_defined_created_event_when_given_name_matches(self):
704
+ class Order(Aggregate, created_event_name="Started"):
705
+ def __init__(self, name):
706
+ self.name = name
707
+ self.confirmed_at = None
708
+ self.pickedup_at = None
709
+
710
+ class Created(AggregateCreated):
711
+ name: str
712
+
713
+ class Started(AggregateCreated):
714
+ name: str
715
+
716
+ order = Order("name")
717
+ pending = order.collect_events()
718
+ self.assertEqual(type(pending[0]).__name__, "Started")
719
+
720
+ def test_defines_created_event_when_given_name_does_not_match(self):
721
+ class Order(Aggregate, created_event_name="Started"):
722
+ def __init__(self, name):
723
+ self.name = name
724
+ self.confirmed_at = None
725
+ self.pickedup_at = None
726
+
727
+ class Created(AggregateCreated):
728
+ name: str
729
+
730
+ order = Order("name")
731
+ pending = order.collect_events()
732
+ self.assertEqual(type(pending[0]).__name__, "Started")
733
+
734
+ def test_raises_when_given_created_event_name_conflicts_with_created_event_class(
735
+ self,
736
+ ):
737
+ with self.assertRaises(TypeError) as cm:
738
+
739
+ class Order(Aggregate, created_event_name="Started"):
740
+ def __init__(self, name):
741
+ self.name = name
742
+ self.confirmed_at = None
743
+ self.pickedup_at = None
744
+
745
+ class Created(AggregateCreated):
746
+ name: str
747
+
748
+ class Started(AggregateCreated):
749
+ name: str
750
+
751
+ _created_event_class = Created
752
+
753
+ self.assertEqual(
754
+ cm.exception.args[0],
755
+ "Can't use both '_created_event_class' and 'created_event_name'",
756
+ )
757
+
758
+ def test_define_create_id(self):
759
+ @dataclass
760
+ class Index(Aggregate):
761
+ name: str
762
+
763
+ @staticmethod
764
+ def create_id(name: str):
765
+ return uuid5(NAMESPACE_URL, f"/pages/{name}")
766
+
767
+ index = Index(name="name")
768
+ self.assertEqual(index.name, "name")
769
+ self.assertEqual(index.id, Index.create_id("name"))
770
+
771
+ def test_id_dataclass_style(self):
772
+ @dataclass
773
+ class MyDataclass:
774
+ id: UUID
775
+ name: str
776
+
777
+ @dataclass
778
+ class Index(Aggregate):
779
+ id: UUID
780
+ name: str
781
+
782
+ @staticmethod
783
+ def create_id(name: str):
784
+ return uuid5(NAMESPACE_URL, f"/pages/{name}")
785
+
786
+ def assert_id_dataclass_style(cls):
787
+ with self.assertRaises(TypeError) as cm:
788
+ cls()
789
+ self.assertEqual(
790
+ cm.exception.args[0],
791
+ f"{get_method_name(cls.__init__)}() missing 2 "
792
+ "required positional arguments: 'id' and 'name'",
793
+ )
794
+
795
+ # Just check it works if used properly.
796
+ name = "name"
797
+ index_id = Index.create_id(name)
798
+ obj = cls(name=name, id=index_id)
799
+ self.assertEqual(obj.id, index_id)
800
+ self.assertEqual(obj.id, index_id)
801
+
802
+ assert_id_dataclass_style(MyDataclass)
803
+ assert_id_dataclass_style(Index)
804
+
805
+ def test_init_has_id_explicitly(self):
806
+ class Index(Aggregate):
807
+ def __init__(self, id: UUID, name: str): # noqa: A002
808
+ self._id = id
809
+ self.name = name
810
+
811
+ @staticmethod
812
+ def create_id(name: str):
813
+ return uuid5(NAMESPACE_URL, f"/pages/{name}")
814
+
815
+ name = "name"
816
+ index_id = Index.create_id(name)
817
+ index = Index(name=name, id=index_id)
818
+ self.assertEqual(index.id, index_id)
819
+
820
+
821
+ class TestSubsequentEvents(TestCase):
822
+ def test_trigger_event(self):
823
+ a = Aggregate()
824
+
825
+ # Check the aggregate can trigger further events.
826
+ a.trigger_event(AggregateEvent)
827
+ self.assertLess(a.created_on, a.modified_on)
828
+
829
+ pending = a.collect_events()
830
+ self.assertEqual(len(pending), 2)
831
+ self.assertIsInstance(pending[0], AggregateCreated)
832
+ self.assertEqual(pending[0].originator_version, 1)
833
+ self.assertIsInstance(pending[1], AggregateEvent)
834
+ self.assertEqual(pending[1].originator_version, 2)
835
+
836
+ def test_event_mutate_raises_originator_version_error(self):
837
+ a = Aggregate()
838
+
839
+ # Try to mutate aggregate with an invalid domain event.
840
+ event = AggregateEvent(
841
+ originator_id=a.id,
842
+ originator_version=a.version, # NB not +1.
843
+ timestamp=AggregateEvent.create_timestamp(),
844
+ )
845
+ # Check raises "VersionError".
846
+ with self.assertRaises(OriginatorVersionError):
847
+ event.mutate(a)
848
+
849
+ def test_event_mutate_raises_originator_id_error(self):
850
+ a = Aggregate()
851
+
852
+ # Try to mutate aggregate with an invalid domain event.
853
+ event = AggregateEvent(
854
+ originator_id=uuid4(),
855
+ originator_version=a.version + 1,
856
+ timestamp=AggregateEvent.create_timestamp(),
857
+ )
858
+ # Check raises "VersionError".
859
+ with self.assertRaises(OriginatorIDError):
860
+ event.mutate(a)
861
+
862
+ def test_raises_when_triggering_event_with_mismatched_args(self):
863
+ class MyAgg(Aggregate):
864
+ @classmethod
865
+ def create(cls):
866
+ return cls._create(event_class=cls.Created, id=uuid4())
867
+
868
+ class ValueUpdated(AggregateEvent):
869
+ a: int
870
+
871
+ a = MyAgg.create()
872
+
873
+ with self.assertRaises(TypeError) as cm:
874
+ a.trigger_event(MyAgg.ValueUpdated)
875
+ self.assertTrue(
876
+ cm.exception.args[0].startswith("Can't construct event"),
877
+ cm.exception.args[0],
878
+ )
879
+ self.assertTrue(
880
+ cm.exception.args[0].endswith(
881
+ "__init__() missing 1 required positional argument: 'a'"
882
+ ),
883
+ cm.exception.args[0],
884
+ )
885
+
886
+ # def test_raises_when_apply_method_returns_value(self):
887
+ # class MyAgg(Aggregate):
888
+ # class ValueUpdated(AggregateEvent):
889
+ # a: int
890
+ #
891
+ # def apply(self, aggregate: TAggregate) -> None:
892
+ # return 1
893
+ #
894
+ # a = MyAgg()
895
+ # with self.assertRaises(TypeError) as cm:
896
+ # a.trigger_event(MyAgg.ValueUpdated, a=1)
897
+ # msg = str(cm.exception.args[0])
898
+ #
899
+ # self.assertTrue(msg.startswith("Unexpected value returned from "), msg)
900
+ # self.assertTrue(
901
+ # msg.endswith(
902
+ # "MyAgg.ValueUpdated.apply(). Values returned from 'apply' methods are"
903
+ # " discarded."
904
+ # ),
905
+ # msg,
906
+ # )
907
+
908
+ def test_eq(self):
909
+ class MyAggregate1(Aggregate):
910
+ id: UUID
911
+
912
+ id_a = uuid4()
913
+ id_b = uuid4()
914
+ a = MyAggregate1(id=id_a)
915
+ self.assertEqual(a, a)
916
+
917
+ b = MyAggregate1(id=id_b)
918
+ self.assertNotEqual(a, b)
919
+
920
+ c = MyAggregate1(id=id_a)
921
+ self.assertNotEqual(a, c)
922
+
923
+ a_copy = a.collect_events()[0].mutate(None)
924
+ self.assertEqual(a, a_copy)
925
+
926
+ # Check the aggregate can trigger further events.
927
+ a.trigger_event(AggregateEvent)
928
+ self.assertNotEqual(a, a_copy)
929
+ a.collect_events()
930
+ self.assertNotEqual(a, a_copy)
931
+
932
+ @dataclass(eq=False)
933
+ class MyAggregate2(Aggregate):
934
+ id: UUID
935
+
936
+ id_a = uuid4()
937
+ id_b = uuid4()
938
+ a = MyAggregate2(id=id_a)
939
+ self.assertEqual(a, a)
940
+
941
+ b = MyAggregate2(id=id_b)
942
+ self.assertNotEqual(a, b)
943
+
944
+ c = MyAggregate2(id=id_a)
945
+ self.assertNotEqual(a, c)
946
+
947
+ a_copy = a.collect_events()[0].mutate(None)
948
+ self.assertEqual(a, a_copy)
949
+
950
+ # Check the aggregate can trigger further events.
951
+ a.trigger_event(AggregateEvent)
952
+ self.assertNotEqual(a, a_copy)
953
+ a.collect_events()
954
+ self.assertNotEqual(a, a_copy)
955
+
956
+ def test_repr_baseclass(self):
957
+ a = Aggregate()
958
+
959
+ expect = (
960
+ f"Aggregate(id={a.id!r}, "
961
+ "version=1, "
962
+ f"created_on={a.created_on!r}, "
963
+ f"modified_on={a.modified_on!r}"
964
+ ")"
965
+ )
966
+ self.assertEqual(expect, repr(a))
967
+
968
+ a.trigger_event(AggregateEvent)
969
+
970
+ expect = (
971
+ f"Aggregate(id={a.id!r}, "
972
+ "version=2, "
973
+ f"created_on={a.created_on!r}, "
974
+ f"modified_on={a.modified_on!r}"
975
+ ")"
976
+ )
977
+ self.assertEqual(expect, repr(a))
978
+
979
+ def test_repr_subclass(self):
980
+ class MyAggregate1(Aggregate):
981
+ a: int
982
+
983
+ class ValueAssigned(AggregateEvent):
984
+ b: int
985
+
986
+ def apply(self, aggregate: TAggregate) -> None:
987
+ aggregate.b = self.b
988
+
989
+ a = MyAggregate1(a=1)
990
+ expect = (
991
+ f"MyAggregate1(id={a.id!r}, "
992
+ "version=1, "
993
+ f"created_on={a.created_on!r}, "
994
+ f"modified_on={a.modified_on!r}, "
995
+ "a=1"
996
+ ")"
997
+ )
998
+ self.assertEqual(expect, repr(a))
999
+
1000
+ a.trigger_event(MyAggregate1.ValueAssigned, b=2)
1001
+
1002
+ expect = (
1003
+ f"MyAggregate1(id={a.id!r}, "
1004
+ "version=2, "
1005
+ f"created_on={a.created_on!r}, "
1006
+ f"modified_on={a.modified_on!r}, "
1007
+ "a=1, "
1008
+ "b=2"
1009
+ ")"
1010
+ )
1011
+ self.assertEqual(expect, repr(a))
1012
+
1013
+ @dataclass(repr=False)
1014
+ class MyAggregate2(Aggregate):
1015
+ a: int
1016
+
1017
+ class ValueAssigned(AggregateEvent):
1018
+ b: int
1019
+
1020
+ def apply(self, aggregate: TAggregate) -> None:
1021
+ aggregate.b = self.b
1022
+
1023
+ a = MyAggregate2(a=1)
1024
+ expect = (
1025
+ f"MyAggregate2(id={a.id!r}, "
1026
+ "version=1, "
1027
+ f"created_on={a.created_on!r}, "
1028
+ f"modified_on={a.modified_on!r}, "
1029
+ "a=1"
1030
+ ")"
1031
+ )
1032
+ self.assertEqual(expect, repr(a))
1033
+
1034
+ a.trigger_event(MyAggregate2.ValueAssigned, b=2)
1035
+
1036
+ expect = (
1037
+ f"MyAggregate2(id={a.id!r}, "
1038
+ "version=2, "
1039
+ f"created_on={a.created_on!r}, "
1040
+ f"modified_on={a.modified_on!r}, "
1041
+ "a=1, "
1042
+ "b=2"
1043
+ ")"
1044
+ )
1045
+ self.assertEqual(expect, repr(a))
1046
+
1047
+
1048
+ class TestAggregateEventsAreSubclassed(TestCase):
1049
+ def test_base_event_class_is_defined_if_missing(self):
1050
+ class MyAggregate(Aggregate):
1051
+ pass
1052
+
1053
+ self.assertTrue(MyAggregate.Event.__qualname__.endswith("MyAggregate.Event"))
1054
+ self.assertTrue(issubclass(MyAggregate.Event, Aggregate.Event))
1055
+ self.assertNotEqual(MyAggregate.Event, Aggregate.Event)
1056
+
1057
+ def test_base_event_class_is_not_redefined_if_exists(self):
1058
+ class MyAggregate(Aggregate):
1059
+ class Event(Aggregate.Event):
1060
+ pass
1061
+
1062
+ my_event_cls = Event
1063
+
1064
+ self.assertTrue(MyAggregate.Event.__qualname__.endswith("MyAggregate.Event"))
1065
+ self.assertEqual(MyAggregate.my_event_cls, MyAggregate.Event)
1066
+
1067
+ def test_aggregate_events_are_subclassed(self):
1068
+ class MyAggregate(Aggregate):
1069
+ class Created(Aggregate.Created):
1070
+ pass
1071
+
1072
+ class Started(Aggregate.Created):
1073
+ pass
1074
+
1075
+ class Ended(Aggregate.Event):
1076
+ pass
1077
+
1078
+ _created_event_class = Started
1079
+
1080
+ self.assertTrue(MyAggregate.Event.__qualname__.endswith("MyAggregate.Event"))
1081
+ self.assertTrue(issubclass(MyAggregate.Created, MyAggregate.Event))
1082
+ self.assertTrue(issubclass(MyAggregate.Started, MyAggregate.Event))
1083
+ self.assertTrue(issubclass(MyAggregate.Ended, MyAggregate.Event))
1084
+ self.assertEqual(MyAggregate._created_event_class, MyAggregate.Started)
1085
+
1086
+ class MySubclass(MyAggregate):
1087
+ class Opened(MyAggregate.Started):
1088
+ pass
1089
+
1090
+ self.assertTrue(MySubclass.Event.__qualname__.endswith("MySubclass.Event"))
1091
+ self.assertTrue(MySubclass.Created.__qualname__.endswith("MySubclass.Created"))
1092
+ self.assertTrue(
1093
+ MySubclass.Started.__qualname__.endswith("MySubclass.Started"),
1094
+ MySubclass.Started.__qualname__,
1095
+ )
1096
+ self.assertTrue(
1097
+ MySubclass.Ended.__qualname__.endswith("MySubclass.Ended"),
1098
+ MySubclass.Ended.__qualname__,
1099
+ )
1100
+
1101
+
1102
+ class TestBankAccount(TestCase):
1103
+ def test_subclass_bank_account(self):
1104
+ # Open an account.
1105
+ account: BankAccount = BankAccount.open(
1106
+ full_name="Alice",
1107
+ email_address="alice@example.com",
1108
+ )
1109
+
1110
+ # Check the created_on.
1111
+ self.assertEqual(account.created_on, account.modified_on)
1112
+
1113
+ # Check the initial balance.
1114
+ self.assertEqual(account.balance, 0)
1115
+
1116
+ # Credit the account.
1117
+ account.append_transaction(Decimal("10.00"))
1118
+
1119
+ # Check the modified_on time was updated.
1120
+ assert account.created_on < account.modified_on
1121
+
1122
+ # Check the balance.
1123
+ self.assertEqual(account.balance, Decimal("10.00"))
1124
+
1125
+ # Credit the account again.
1126
+ account.append_transaction(Decimal("10.00"))
1127
+
1128
+ # Check the balance.
1129
+ self.assertEqual(account.balance, Decimal("20.00"))
1130
+
1131
+ # Debit the account.
1132
+ account.append_transaction(Decimal("-15.00"))
1133
+
1134
+ # Check the balance.
1135
+ self.assertEqual(account.balance, Decimal("5.00"))
1136
+
1137
+ # Fail to debit account (insufficient funds).
1138
+ with self.assertRaises(InsufficientFundsError):
1139
+ account.append_transaction(Decimal("-15.00"))
1140
+
1141
+ # Increase the overdraft limit.
1142
+ account.set_overdraft_limit(Decimal("100.00"))
1143
+
1144
+ # Debit the account.
1145
+ account.append_transaction(Decimal("-15.00"))
1146
+
1147
+ # Check the balance.
1148
+ self.assertEqual(account.balance, Decimal("-10.00"))
1149
+
1150
+ # Close the account.
1151
+ account.close()
1152
+
1153
+ # Fail to debit account (account closed).
1154
+ with self.assertRaises(AccountClosedError):
1155
+ account.append_transaction(Decimal("-15.00"))
1156
+
1157
+ # Collect pending events.
1158
+ pending = account.collect_events()
1159
+ self.assertEqual(len(pending), 7)