@emkodev/emkore 1.0.3

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/DEVELOPER_GUIDE.md +227 -0
  3. package/LICENSE +21 -0
  4. package/README.md +126 -0
  5. package/bun.lock +22 -0
  6. package/example/README.md +200 -0
  7. package/example/create-user.interactor.ts +88 -0
  8. package/example/dto/user.dto.ts +34 -0
  9. package/example/entity/user.entity.ts +54 -0
  10. package/example/index.ts +18 -0
  11. package/example/interface/create-user.usecase.ts +93 -0
  12. package/example/interface/user.repository.ts +23 -0
  13. package/mod.ts +1 -0
  14. package/package.json +32 -0
  15. package/src/common/abstract.actor.ts +59 -0
  16. package/src/common/abstract.entity.ts +59 -0
  17. package/src/common/abstract.interceptor.ts +17 -0
  18. package/src/common/abstract.repository.ts +162 -0
  19. package/src/common/abstract.usecase.ts +113 -0
  20. package/src/common/config/config-registry.ts +190 -0
  21. package/src/common/config/config-section.ts +106 -0
  22. package/src/common/exception/authorization-exception.ts +28 -0
  23. package/src/common/exception/repository-exception.ts +46 -0
  24. package/src/common/interceptor/audit-log.interceptor.ts +181 -0
  25. package/src/common/interceptor/authorization.interceptor.ts +252 -0
  26. package/src/common/interceptor/performance.interceptor.ts +101 -0
  27. package/src/common/llm/api-definition.type.ts +185 -0
  28. package/src/common/pattern/unit-of-work.ts +78 -0
  29. package/src/common/platform/env.ts +38 -0
  30. package/src/common/registry/usecase-registry.ts +80 -0
  31. package/src/common/type/interceptor-context.type.ts +25 -0
  32. package/src/common/type/json-schema.type.ts +80 -0
  33. package/src/common/type/json.type.ts +5 -0
  34. package/src/common/type/lowercase.type.ts +48 -0
  35. package/src/common/type/metadata.type.ts +5 -0
  36. package/src/common/type/money.class.ts +384 -0
  37. package/src/common/type/permission.type.ts +43 -0
  38. package/src/common/validation/validation-result.ts +52 -0
  39. package/src/common/validation/validators.ts +441 -0
  40. package/src/index.ts +95 -0
  41. package/test/unit/abstract-actor.test.ts +608 -0
  42. package/test/unit/actor.test.ts +89 -0
  43. package/test/unit/api-definition.test.ts +628 -0
  44. package/test/unit/authorization.test.ts +101 -0
  45. package/test/unit/entity.test.ts +95 -0
  46. package/test/unit/money.test.ts +480 -0
  47. package/test/unit/validation.test.ts +138 -0
  48. package/tsconfig.json +18 -0
@@ -0,0 +1,101 @@
1
+ import { test, expect } from "bun:test";
2
+ import { AuthorizationInterceptor, ResourceScope } from "../../mod.ts";
3
+
4
+ test("AuthorizationInterceptor - scope satisfaction matrix", () => {
5
+ // Access the private static field for testing
6
+ const interceptor = new AuthorizationInterceptor();
7
+
8
+ // Test that the SCOPE_SATISFIES map is correctly initialized
9
+ const scopeSatisfies = (interceptor as any as {
10
+ constructor: {
11
+ SCOPE_SATISFIES: Map<string, Set<string>>;
12
+ };
13
+ }).constructor.SCOPE_SATISFIES;
14
+
15
+ // ALL scope should satisfy all other scopes
16
+ expect(
17
+ scopeSatisfies.get(ResourceScope.ALL)?.has(ResourceScope.ALL),
18
+ ).toEqual(true);
19
+ expect(
20
+ scopeSatisfies.get(ResourceScope.ALL)?.has(ResourceScope.BUSINESS),
21
+ ).toEqual(true);
22
+ expect(
23
+ scopeSatisfies.get(ResourceScope.ALL)?.has(ResourceScope.PROJECT),
24
+ ).toEqual(true);
25
+ expect(
26
+ scopeSatisfies.get(ResourceScope.ALL)?.has(ResourceScope.TEAM),
27
+ ).toEqual(true);
28
+ expect(
29
+ scopeSatisfies.get(ResourceScope.ALL)?.has(ResourceScope.OWNED),
30
+ ).toEqual(true);
31
+
32
+ // BUSINESS scope should satisfy BUSINESS, PROJECT, TEAM, and OWNED
33
+ expect(
34
+ scopeSatisfies.get(ResourceScope.BUSINESS)?.has(ResourceScope.ALL),
35
+ ).toEqual(false);
36
+ expect(
37
+ scopeSatisfies.get(ResourceScope.BUSINESS)?.has(ResourceScope.BUSINESS),
38
+ ).toEqual(true);
39
+ expect(
40
+ scopeSatisfies.get(ResourceScope.BUSINESS)?.has(ResourceScope.PROJECT),
41
+ ).toEqual(true);
42
+ expect(
43
+ scopeSatisfies.get(ResourceScope.BUSINESS)?.has(ResourceScope.TEAM),
44
+ ).toEqual(true);
45
+ expect(
46
+ scopeSatisfies.get(ResourceScope.BUSINESS)?.has(ResourceScope.OWNED),
47
+ ).toEqual(true);
48
+
49
+ // PROJECT scope should satisfy PROJECT and OWNED (parallel to TEAM)
50
+ expect(
51
+ scopeSatisfies.get(ResourceScope.PROJECT)?.has(ResourceScope.ALL),
52
+ ).toEqual(false);
53
+ expect(
54
+ scopeSatisfies.get(ResourceScope.PROJECT)?.has(ResourceScope.BUSINESS),
55
+ ).toEqual(false);
56
+ expect(
57
+ scopeSatisfies.get(ResourceScope.PROJECT)?.has(ResourceScope.PROJECT),
58
+ ).toEqual(true);
59
+ expect(
60
+ scopeSatisfies.get(ResourceScope.PROJECT)?.has(ResourceScope.TEAM),
61
+ ).toEqual(false);
62
+ expect(
63
+ scopeSatisfies.get(ResourceScope.PROJECT)?.has(ResourceScope.OWNED),
64
+ ).toEqual(true);
65
+
66
+ // TEAM scope should satisfy TEAM and OWNED (parallel to PROJECT)
67
+ expect(
68
+ scopeSatisfies.get(ResourceScope.TEAM)?.has(ResourceScope.ALL),
69
+ ).toEqual(false);
70
+ expect(
71
+ scopeSatisfies.get(ResourceScope.TEAM)?.has(ResourceScope.BUSINESS),
72
+ ).toEqual(false);
73
+ expect(
74
+ scopeSatisfies.get(ResourceScope.TEAM)?.has(ResourceScope.PROJECT),
75
+ ).toEqual(false);
76
+ expect(
77
+ scopeSatisfies.get(ResourceScope.TEAM)?.has(ResourceScope.TEAM),
78
+ ).toEqual(true);
79
+ expect(
80
+ scopeSatisfies.get(ResourceScope.TEAM)?.has(ResourceScope.OWNED),
81
+ ).toEqual(true);
82
+
83
+ // OWNED scope should only satisfy OWNED
84
+ expect(
85
+ scopeSatisfies.get(ResourceScope.OWNED)?.has(ResourceScope.ALL),
86
+ ).toEqual(false);
87
+ expect(
88
+ scopeSatisfies.get(ResourceScope.OWNED)?.has(ResourceScope.BUSINESS),
89
+ ).toEqual(false);
90
+ expect(
91
+ scopeSatisfies.get(ResourceScope.OWNED)?.has(ResourceScope.PROJECT),
92
+ ).toEqual(false);
93
+ expect(
94
+ scopeSatisfies.get(ResourceScope.OWNED)?.has(ResourceScope.TEAM),
95
+ ).toEqual(false);
96
+ expect(
97
+ scopeSatisfies.get(ResourceScope.OWNED)?.has(ResourceScope.OWNED),
98
+ ).toEqual(true);
99
+
100
+ console.log("✓ Scope satisfaction matrix is correctly initialized");
101
+ });
@@ -0,0 +1,95 @@
1
+ import { test, expect } from "bun:test";
2
+ import { type Dto, Entity } from "../../mod.ts";
3
+
4
+ class TestEntity extends Entity {
5
+ constructor(dto: Dto) {
6
+ super(dto);
7
+ }
8
+ }
9
+
10
+ test("Entity - constructor", () => {
11
+ const dto: Dto = {
12
+ id: "test-123",
13
+ ownerId: "owner-456",
14
+ createdAt: "2024-01-01T00:00:00.000Z",
15
+ updatedAt: "2024-01-02T00:00:00.000Z",
16
+ };
17
+
18
+ const entity = new TestEntity(dto);
19
+
20
+ expect(entity.id).toEqual("test-123");
21
+ expect(entity.ownerId).toEqual("owner-456");
22
+ expect(entity.createdAt.toISOString()).toEqual("2024-01-01T00:00:00.000Z");
23
+ expect(entity.updatedAt.toISOString()).toEqual("2024-01-02T00:00:00.000Z");
24
+ expect(entity.deletedAt).toEqual(undefined);
25
+ expect(entity.isDeleted).toEqual(false);
26
+ });
27
+
28
+ test("Entity - with deletedAt", () => {
29
+ const dto: Dto = {
30
+ id: "test-123",
31
+ ownerId: "owner-456",
32
+ createdAt: "2024-01-01T00:00:00.000Z",
33
+ updatedAt: "2024-01-02T00:00:00.000Z",
34
+ deletedAt: "2024-01-03T00:00:00.000Z",
35
+ };
36
+
37
+ const entity = new TestEntity(dto);
38
+
39
+ expect(entity.deletedAt?.toISOString()).toEqual("2024-01-03T00:00:00.000Z");
40
+ expect(entity.isDeleted).toEqual(true);
41
+ });
42
+
43
+ test("Entity - toDto", () => {
44
+ const dto: Dto = {
45
+ id: "test-123",
46
+ ownerId: "owner-456",
47
+ createdAt: "2024-01-01T00:00:00.000Z",
48
+ updatedAt: "2024-01-02T00:00:00.000Z",
49
+ deletedAt: "2024-01-03T00:00:00.000Z",
50
+ };
51
+
52
+ const entity = new TestEntity(dto);
53
+ const result = entity.toDto();
54
+
55
+ expect(result.id).toEqual(dto.id);
56
+ expect(result.ownerId).toEqual(dto.ownerId);
57
+ expect(result.createdAt).toEqual(dto.createdAt);
58
+ expect(result.updatedAt).toEqual(dto.updatedAt);
59
+ expect(result.deletedAt).toEqual(dto.deletedAt);
60
+ });
61
+
62
+ test("Entity - generateId with valid prefix", () => {
63
+ const id = Entity.generateId("abcd");
64
+
65
+ expect(id.startsWith("abcd-")).toEqual(true);
66
+ expect(id.length).toEqual(41); // 4 (prefix) + 1 (-) + 36 (uuid)
67
+
68
+ // Test UUID format after prefix
69
+ const uuidPart = id.slice(5);
70
+ const uuidRegex =
71
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
72
+ expect(uuidRegex.test(uuidPart)).toEqual(true);
73
+ });
74
+
75
+ test("Entity - generateId with invalid prefix", () => {
76
+ // Too short
77
+ expect(() => Entity.generateId("abc")).toThrow(
78
+ "Prefix must be exactly four hexadecimal characters",
79
+ );
80
+
81
+ // Too long
82
+ expect(() => Entity.generateId("abcde")).toThrow(
83
+ "Prefix must be exactly four hexadecimal characters",
84
+ );
85
+
86
+ // Invalid characters
87
+ expect(() => Entity.generateId("ghij")).toThrow(
88
+ "Prefix must be exactly four hexadecimal characters",
89
+ );
90
+
91
+ // With hyphen
92
+ expect(() => Entity.generateId("ab-d")).toThrow(
93
+ "Prefix must be exactly four hexadecimal characters",
94
+ );
95
+ });
@@ -0,0 +1,480 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Money } from "../../src/common/type/money.class.ts";
3
+
4
+ // ============================================================================
5
+ // Construction Tests
6
+ // ============================================================================
7
+
8
+ test("Money - constructor with valid cents", () => {
9
+ const money = new Money(14999, "USD");
10
+ expect(money.cents).toEqual(14999);
11
+ expect(money.currency).toEqual("USD");
12
+ });
13
+
14
+ test("Money - constructor normalizes currency to uppercase", () => {
15
+ const money = new Money(100, "usd");
16
+ expect(money.currency).toEqual("USD");
17
+ });
18
+
19
+ test("Money - constructor rounds fractional cents", () => {
20
+ const money = new Money(149.99, "USD");
21
+ expect(money.cents).toEqual(150);
22
+ });
23
+
24
+ test("Money - constructor throws on null cents", () => {
25
+ expect(() => new Money(null as unknown as number, "USD")).toThrow(
26
+ "Money value cannot be null - amount is required",
27
+ );
28
+ });
29
+
30
+ test("Money - constructor throws on undefined cents", () => {
31
+ expect(() => new Money(undefined as unknown as number, "USD")).toThrow(
32
+ "Money value cannot be null - amount is required",
33
+ );
34
+ });
35
+
36
+ test("Money - constructor throws on empty currency", () => {
37
+ expect(() => new Money(100, "")).toThrow(
38
+ "Currency must be a non-empty string",
39
+ );
40
+ });
41
+
42
+ test("Money - constructor throws on non-string currency", () => {
43
+ expect(() => new Money(100, null as unknown as string)).toThrow(
44
+ "Currency must be a non-empty string",
45
+ );
46
+ });
47
+
48
+ test("Money - constructor requires currency", () => {
49
+ const money = new Money(100, "USD");
50
+ expect(money.currency).toEqual("USD");
51
+ expect(money.cents).toEqual(100);
52
+ });
53
+
54
+ // ============================================================================
55
+ // Factory Methods
56
+ // ============================================================================
57
+
58
+ test("Money.fromCents - creates Money from cents", () => {
59
+ const money = Money.fromCents(14999, "USD");
60
+ expect(money.cents).toEqual(14999);
61
+ expect(money.currency).toEqual("USD");
62
+ });
63
+
64
+ test("Money.fromMajorUnits - converts dollars to cents", () => {
65
+ const money = Money.fromMajorUnits(149.99, "USD");
66
+ expect(money.cents).toEqual(14999);
67
+ });
68
+
69
+ test("Money.fromMajorUnits - handles integer amounts", () => {
70
+ const money = Money.fromMajorUnits(100, "USD");
71
+ expect(money.cents).toEqual(10000);
72
+ });
73
+
74
+ test("Money.fromMajorUnits - rounds fractional cents", () => {
75
+ const money = Money.fromMajorUnits(149.999, "USD");
76
+ expect(money.cents).toEqual(15000); // 149.999 * 100 = 14999.9 → 15000
77
+ });
78
+
79
+ test("Money.fromMajorUnits - handles floating point precision", () => {
80
+ // 0.1 + 0.2 = 0.30000000000000004 in JavaScript
81
+ const money = Money.fromMajorUnits(0.1 + 0.2, "USD");
82
+ expect(money.cents).toEqual(30); // Should round to 30 cents
83
+ });
84
+
85
+ test("Money.zero - creates zero Money", () => {
86
+ const money = Money.zero("EUR");
87
+ expect(money.cents).toEqual(0);
88
+ expect(money.currency).toEqual("EUR");
89
+ });
90
+
91
+ test("Money.zero - creates zero with specified currency", () => {
92
+ const usd = Money.zero("USD");
93
+ expect(usd.currency).toEqual("USD");
94
+ expect(usd.cents).toEqual(0);
95
+
96
+ const eur = Money.zero("EUR");
97
+ expect(eur.currency).toEqual("EUR");
98
+ expect(eur.cents).toEqual(0);
99
+ });
100
+
101
+ // ============================================================================
102
+ // Arithmetic Operations
103
+ // ============================================================================
104
+
105
+ test("Money.add - adds two Money amounts", () => {
106
+ const a = Money.fromCents(100, "USD");
107
+ const b = Money.fromCents(50, "USD");
108
+ const result = a.add(b);
109
+
110
+ expect(result.cents).toEqual(150);
111
+ expect(result.currency).toEqual("USD");
112
+ });
113
+
114
+ test("Money.add - returns new instance (immutability)", () => {
115
+ const a = Money.fromCents(100, "USD");
116
+ const b = Money.fromCents(50, "USD");
117
+ const result = a.add(b);
118
+
119
+ expect(a.cents).toEqual(100); // Original unchanged
120
+ expect(b.cents).toEqual(50); // Original unchanged
121
+ expect(result.cents).toEqual(150);
122
+ });
123
+
124
+ test("Money.add - throws on currency mismatch", () => {
125
+ const usd = Money.fromCents(100, "USD");
126
+ const eur = Money.fromCents(100, "EUR");
127
+
128
+ expect(() => usd.add(eur)).toThrow(
129
+ "Cannot operate on Money with different currencies: USD and EUR",
130
+ );
131
+ });
132
+
133
+ test("Money.subtract - subtracts two Money amounts", () => {
134
+ const a = Money.fromCents(100, "USD");
135
+ const b = Money.fromCents(30, "USD");
136
+ const result = a.subtract(b);
137
+
138
+ expect(result.cents).toEqual(70);
139
+ });
140
+
141
+ test("Money.subtract - can result in negative", () => {
142
+ const a = Money.fromCents(50, "USD");
143
+ const b = Money.fromCents(100, "USD");
144
+ const result = a.subtract(b);
145
+
146
+ expect(result.cents).toEqual(-50);
147
+ });
148
+
149
+ test("Money.subtract - throws on currency mismatch", () => {
150
+ const usd = Money.fromCents(100, "USD");
151
+ const eur = Money.fromCents(50, "EUR");
152
+
153
+ expect(() => usd.subtract(eur)).toThrow(
154
+ "different currencies",
155
+ );
156
+ });
157
+
158
+ test("Money.multiply - multiplies by integer", () => {
159
+ const money = Money.fromCents(100, "USD");
160
+ const result = money.multiply(3);
161
+
162
+ expect(result.cents).toEqual(300);
163
+ });
164
+
165
+ test("Money.multiply - multiplies by decimal", () => {
166
+ const money = Money.fromCents(100, "USD");
167
+ const result = money.multiply(1.5);
168
+
169
+ expect(result.cents).toEqual(150);
170
+ });
171
+
172
+ test("Money.multiply - rounds fractional result", () => {
173
+ const money = Money.fromCents(100, "USD");
174
+ const result = money.multiply(0.33);
175
+
176
+ expect(result.cents).toEqual(33); // 100 * 0.33 = 33
177
+ });
178
+
179
+ test("Money.multiply - handles tax calculation", () => {
180
+ const price = Money.fromCents(10000, "USD"); // $100.00
181
+ const withTax = price.multiply(1.08); // 8% tax
182
+
183
+ expect(withTax.cents).toEqual(10800); // $108.00
184
+ });
185
+
186
+ test("Money.multiply - returns new instance", () => {
187
+ const money = Money.fromCents(100, "USD");
188
+ const result = money.multiply(2);
189
+
190
+ expect(money.cents).toEqual(100); // Original unchanged
191
+ expect(result.cents).toEqual(200);
192
+ });
193
+
194
+ test("Money.divide - divides by integer", () => {
195
+ const money = Money.fromCents(100, "USD");
196
+ const result = money.divide(2);
197
+
198
+ expect(result.cents).toEqual(50);
199
+ });
200
+
201
+ test("Money.divide - rounds fractional result", () => {
202
+ const money = Money.fromCents(100, "USD");
203
+ const result = money.divide(3);
204
+
205
+ expect(result.cents).toEqual(33); // 100 / 3 = 33.333... → 33
206
+ });
207
+
208
+ test("Money.divide - throws on division by zero", () => {
209
+ const money = Money.fromCents(100, "USD");
210
+
211
+ expect(() => money.divide(0)).toThrow(
212
+ "Cannot divide money by zero",
213
+ );
214
+ });
215
+
216
+ test("Money.divide - returns new instance", () => {
217
+ const money = Money.fromCents(100, "USD");
218
+ const result = money.divide(2);
219
+
220
+ expect(money.cents).toEqual(100); // Original unchanged
221
+ expect(result.cents).toEqual(50);
222
+ });
223
+
224
+ // ============================================================================
225
+ // Comparison Operations
226
+ // ============================================================================
227
+
228
+ test("Money.equals - returns true for equal amounts", () => {
229
+ const a = Money.fromCents(100, "USD");
230
+ const b = Money.fromCents(100, "USD");
231
+
232
+ expect(a.equals(b)).toEqual(true);
233
+ });
234
+
235
+ test("Money.equals - returns false for different amounts", () => {
236
+ const a = Money.fromCents(100, "USD");
237
+ const b = Money.fromCents(50, "USD");
238
+
239
+ expect(a.equals(b)).toEqual(false);
240
+ });
241
+
242
+ test("Money.equals - throws on currency mismatch", () => {
243
+ const usd = Money.fromCents(100, "USD");
244
+ const eur = Money.fromCents(100, "EUR");
245
+
246
+ expect(() => usd.equals(eur)).toThrow(
247
+ "different currencies",
248
+ );
249
+ });
250
+
251
+ test("Money.lessThan - compares amounts", () => {
252
+ const a = Money.fromCents(50, "USD");
253
+ const b = Money.fromCents(100, "USD");
254
+
255
+ expect(a.lessThan(b)).toEqual(true);
256
+ expect(b.lessThan(a)).toEqual(false);
257
+ });
258
+
259
+ test("Money.lessThan - returns false for equal amounts", () => {
260
+ const a = Money.fromCents(100, "USD");
261
+ const b = Money.fromCents(100, "USD");
262
+
263
+ expect(a.lessThan(b)).toEqual(false);
264
+ });
265
+
266
+ test("Money.lessThan - throws on currency mismatch", () => {
267
+ const usd = Money.fromCents(50, "USD");
268
+ const eur = Money.fromCents(100, "EUR");
269
+
270
+ expect(() => usd.lessThan(eur)).toThrow(
271
+ "different currencies",
272
+ );
273
+ });
274
+
275
+ test("Money.lessThanOrEqual - compares amounts", () => {
276
+ const a = Money.fromCents(50, "USD");
277
+ const b = Money.fromCents(100, "USD");
278
+ const c = Money.fromCents(50, "USD");
279
+
280
+ expect(a.lessThanOrEqual(b)).toEqual(true);
281
+ expect(a.lessThanOrEqual(c)).toEqual(true);
282
+ expect(b.lessThanOrEqual(a)).toEqual(false);
283
+ });
284
+
285
+ test("Money.greaterThan - compares amounts", () => {
286
+ const a = Money.fromCents(100, "USD");
287
+ const b = Money.fromCents(50, "USD");
288
+
289
+ expect(a.greaterThan(b)).toEqual(true);
290
+ expect(b.greaterThan(a)).toEqual(false);
291
+ });
292
+
293
+ test("Money.greaterThan - returns false for equal amounts", () => {
294
+ const a = Money.fromCents(100, "USD");
295
+ const b = Money.fromCents(100, "USD");
296
+
297
+ expect(a.greaterThan(b)).toEqual(false);
298
+ });
299
+
300
+ test("Money.greaterThanOrEqual - compares amounts", () => {
301
+ const a = Money.fromCents(100, "USD");
302
+ const b = Money.fromCents(50, "USD");
303
+ const c = Money.fromCents(100, "USD");
304
+
305
+ expect(a.greaterThanOrEqual(b)).toEqual(true);
306
+ expect(a.greaterThanOrEqual(c)).toEqual(true);
307
+ expect(b.greaterThanOrEqual(a)).toEqual(false);
308
+ });
309
+
310
+ // ============================================================================
311
+ // Formatting Tests
312
+ // ============================================================================
313
+
314
+ test("Money.format - formats USD with en-US locale", () => {
315
+ const money = Money.fromCents(14999, "USD");
316
+ expect(money.format("en-US")).toEqual("$149.99");
317
+ });
318
+
319
+ test("Money.format - formats EUR with en-US locale", () => {
320
+ const money = Money.fromCents(9999, "EUR");
321
+ expect(money.format("en-US")).toEqual("€99.99");
322
+ });
323
+
324
+ test("Money.format - formats USD with de-DE locale", () => {
325
+ const money = Money.fromCents(14999, "USD");
326
+ const formatted = money.format("de-DE");
327
+ // German locale uses comma as decimal separator
328
+ expect(formatted.includes("149,99")).toEqual(true);
329
+ });
330
+
331
+ test("Money.format - handles zero", () => {
332
+ const money = Money.zero("USD");
333
+ expect(money.format("en-US")).toEqual("$0.00");
334
+ });
335
+
336
+ test("Money.format - handles negative amounts", () => {
337
+ const money = Money.fromCents(-5000, "USD");
338
+ expect(money.format("en-US")).toEqual("-$50.00");
339
+ });
340
+
341
+ test("Money.format - handles large amounts", () => {
342
+ const money = Money.fromCents(123456789, "USD");
343
+ expect(money.format("en-US")).toEqual("$1,234,567.89");
344
+ });
345
+
346
+ test("Money.format - defaults to en-US locale", () => {
347
+ const money = Money.fromCents(14999, "USD");
348
+ expect(money.format()).toEqual("$149.99");
349
+ });
350
+
351
+ test("Money.formatNumber - formats without currency symbol", () => {
352
+ const money = Money.fromCents(14999, "USD");
353
+ expect(money.formatNumber("en-US")).toEqual("149.99");
354
+ });
355
+
356
+ test("Money.formatNumber - formats with de-DE locale", () => {
357
+ const money = Money.fromCents(14999, "USD");
358
+ expect(money.formatNumber("de-DE")).toEqual("149,99");
359
+ });
360
+
361
+ test("Money.formatNumber - handles zero", () => {
362
+ const money = Money.zero("USD");
363
+ expect(money.formatNumber("en-US")).toEqual("0.00");
364
+ });
365
+
366
+ test("Money.formatNumber - handles negative", () => {
367
+ const money = Money.fromCents(-5000, "USD");
368
+ expect(money.formatNumber("en-US")).toEqual("-50.00");
369
+ });
370
+
371
+ test("Money.formatNumber - defaults to en-US locale", () => {
372
+ const money = Money.fromCents(14999, "USD");
373
+ expect(money.formatNumber()).toEqual("149.99");
374
+ });
375
+
376
+ // ============================================================================
377
+ // Serialization Tests
378
+ // ============================================================================
379
+
380
+ test("Money.toString - converts to decimal string", () => {
381
+ const money = Money.fromCents(14999, "USD");
382
+ expect(money.toString()).toEqual("149.99");
383
+ });
384
+
385
+ test("Money.toString - handles zero", () => {
386
+ const money = Money.zero("USD");
387
+ expect(money.toString()).toEqual("0");
388
+ });
389
+
390
+ test("Money.toString - handles negative", () => {
391
+ const money = Money.fromCents(-5000, "USD");
392
+ expect(money.toString()).toEqual("-50");
393
+ });
394
+
395
+ test("Money.toJson - returns cents as number", () => {
396
+ const money = Money.fromCents(14999, "USD");
397
+ expect(money.toJson()).toEqual(14999);
398
+ });
399
+
400
+ test("Money.toJson - handles zero", () => {
401
+ const money = Money.zero("USD");
402
+ expect(money.toJson()).toEqual(0);
403
+ });
404
+
405
+ test("Money.toJson - handles negative", () => {
406
+ const money = Money.fromCents(-5000, "USD");
407
+ expect(money.toJson()).toEqual(-5000);
408
+ });
409
+
410
+ // ============================================================================
411
+ // Getters
412
+ // ============================================================================
413
+
414
+ test("Money.cents - returns amount in cents", () => {
415
+ const money = Money.fromCents(14999, "USD");
416
+ expect(money.cents).toEqual(14999);
417
+ });
418
+
419
+ test("Money.currency - returns currency code", () => {
420
+ const money = Money.fromCents(100, "EUR");
421
+ expect(money.currency).toEqual("EUR");
422
+ });
423
+
424
+ // ============================================================================
425
+ // Edge Cases
426
+ // ============================================================================
427
+
428
+ test("Money - handles very large amounts", () => {
429
+ const money = Money.fromCents(Number.MAX_SAFE_INTEGER, "USD");
430
+ expect(money.cents).toEqual(Number.MAX_SAFE_INTEGER);
431
+ });
432
+
433
+ test("Money - handles very small negative amounts", () => {
434
+ const money = Money.fromCents(-1, "USD");
435
+ expect(money.cents).toEqual(-1);
436
+ expect(money.format("en-US")).toEqual("-$0.01");
437
+ });
438
+
439
+ test("Money - arithmetic preserves currency", () => {
440
+ const eur = Money.fromCents(100, "EUR");
441
+ const result = eur.multiply(2).add(eur).subtract(eur);
442
+
443
+ expect(result.currency).toEqual("EUR");
444
+ });
445
+
446
+ test("Money - supports Japanese Yen (no minor units)", () => {
447
+ const yen = Money.fromCents(1000000, "JPY");
448
+ expect(yen.format("en-US")).toEqual("¥10,000");
449
+ });
450
+
451
+ test("Money - complex calculation maintains precision", () => {
452
+ const item1 = Money.fromCents(1999, "USD"); // $19.99
453
+ const item2 = Money.fromCents(2999, "USD"); // $29.99
454
+ const subtotal = item1.add(item2); // $49.98
455
+ const total = subtotal.multiply(1.08); // $53.98 (rounded)
456
+
457
+ expect(subtotal.cents).toEqual(4998);
458
+ expect(total.cents).toEqual(5398);
459
+ expect(total.format("en-US")).toEqual("$53.98");
460
+ });
461
+
462
+ test("Money - chained operations", () => {
463
+ const result = Money.fromCents(100, "USD")
464
+ .multiply(3) // 300
465
+ .add(Money.fromCents(50, "USD")) // 350
466
+ .subtract(Money.fromCents(100, "USD")) // 250
467
+ .divide(2); // 125
468
+
469
+ expect(result.cents).toEqual(125);
470
+ });
471
+
472
+ test("Money - percentage discount calculation", () => {
473
+ const price = Money.fromCents(10000, "USD"); // $100.00
474
+ const discount = price.multiply(0.2); // 20% discount = $20.00
475
+ const finalPrice = price.subtract(discount); // $80.00
476
+
477
+ expect(discount.cents).toEqual(2000);
478
+ expect(finalPrice.cents).toEqual(8000);
479
+ expect(finalPrice.format("en-US")).toEqual("$80.00");
480
+ });