@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.
- package/CHANGELOG.md +269 -0
- package/DEVELOPER_GUIDE.md +227 -0
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/bun.lock +22 -0
- package/example/README.md +200 -0
- package/example/create-user.interactor.ts +88 -0
- package/example/dto/user.dto.ts +34 -0
- package/example/entity/user.entity.ts +54 -0
- package/example/index.ts +18 -0
- package/example/interface/create-user.usecase.ts +93 -0
- package/example/interface/user.repository.ts +23 -0
- package/mod.ts +1 -0
- package/package.json +32 -0
- package/src/common/abstract.actor.ts +59 -0
- package/src/common/abstract.entity.ts +59 -0
- package/src/common/abstract.interceptor.ts +17 -0
- package/src/common/abstract.repository.ts +162 -0
- package/src/common/abstract.usecase.ts +113 -0
- package/src/common/config/config-registry.ts +190 -0
- package/src/common/config/config-section.ts +106 -0
- package/src/common/exception/authorization-exception.ts +28 -0
- package/src/common/exception/repository-exception.ts +46 -0
- package/src/common/interceptor/audit-log.interceptor.ts +181 -0
- package/src/common/interceptor/authorization.interceptor.ts +252 -0
- package/src/common/interceptor/performance.interceptor.ts +101 -0
- package/src/common/llm/api-definition.type.ts +185 -0
- package/src/common/pattern/unit-of-work.ts +78 -0
- package/src/common/platform/env.ts +38 -0
- package/src/common/registry/usecase-registry.ts +80 -0
- package/src/common/type/interceptor-context.type.ts +25 -0
- package/src/common/type/json-schema.type.ts +80 -0
- package/src/common/type/json.type.ts +5 -0
- package/src/common/type/lowercase.type.ts +48 -0
- package/src/common/type/metadata.type.ts +5 -0
- package/src/common/type/money.class.ts +384 -0
- package/src/common/type/permission.type.ts +43 -0
- package/src/common/validation/validation-result.ts +52 -0
- package/src/common/validation/validators.ts +441 -0
- package/src/index.ts +95 -0
- package/test/unit/abstract-actor.test.ts +608 -0
- package/test/unit/actor.test.ts +89 -0
- package/test/unit/api-definition.test.ts +628 -0
- package/test/unit/authorization.test.ts +101 -0
- package/test/unit/entity.test.ts +95 -0
- package/test/unit/money.test.ts +480 -0
- package/test/unit/validation.test.ts +138 -0
- 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
|
+
});
|