@tstdl/base 0.93.163 → 0.93.165
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/authentication/client/authentication.service.js +11 -6
- package/circuit-breaker/postgres/circuit-breaker.d.ts +0 -3
- package/circuit-breaker/postgres/circuit-breaker.js +9 -12
- package/circuit-breaker/tests/circuit-breaker.test.js +7 -2
- package/object-storage/s3/tests/s3.object-storage.integration.test.js +2 -2
- package/orm/tests/build-jsonb.test.js +5 -5
- package/orm/tests/query-converter-complex.test.js +5 -5
- package/orm/tests/repository-cti-complex.test.js +54 -73
- package/orm/tests/repository-cti-embedded.test.js +9 -19
- package/orm/tests/repository-cti-search.test.js +12 -23
- package/orm/tests/repository-edge-cases.test.js +81 -119
- package/orm/tests/repository-mapping.test.js +3 -9
- package/orm/tests/repository-search-coverage.test.js +52 -74
- package/orm/tests/repository-transactions-nested.test.js +96 -120
- package/orm/tests/transactional.test.js +5 -14
- package/package.json +11 -11
- package/task-queue/tests/optimization-edge-cases.test.js +11 -14
- package/task-queue/tests/queue.test.js +29 -2
- package/testing/README.md +23 -35
- package/testing/integration-setup.d.ts +54 -7
- package/testing/integration-setup.js +147 -21
|
@@ -8,17 +8,15 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
10
|
import { sql } from 'drizzle-orm';
|
|
11
|
-
import {
|
|
12
|
-
import { Injector, runInInjectionContext } from '../../injector/index.js';
|
|
11
|
+
import { describe, expect } from 'vitest';
|
|
13
12
|
import { StringProperty } from '../../schema/index.js';
|
|
13
|
+
import { getIntegrationTest } from '../../testing/index.js';
|
|
14
14
|
import { Table } from '../decorators.js';
|
|
15
15
|
import { Entity } from '../entity.js';
|
|
16
|
-
import { configureOrm, Database } from '../server/index.js';
|
|
17
16
|
import { injectRepository } from '../server/repository.js';
|
|
17
|
+
const schema = 'test_orm_tx_nested';
|
|
18
|
+
const test = getIntegrationTest({ orm: { schema } });
|
|
18
19
|
describe('ORM Repository Transactions Nested (Integration)', () => {
|
|
19
|
-
let injector;
|
|
20
|
-
let db;
|
|
21
|
-
const schema = 'test_orm_tx_nested';
|
|
22
20
|
let TxEntity = class TxEntity extends Entity {
|
|
23
21
|
name;
|
|
24
22
|
};
|
|
@@ -29,15 +27,7 @@ describe('ORM Repository Transactions Nested (Integration)', () => {
|
|
|
29
27
|
TxEntity = __decorate([
|
|
30
28
|
Table('tx_entities', { schema })
|
|
31
29
|
], TxEntity);
|
|
32
|
-
|
|
33
|
-
injector = new Injector('Test');
|
|
34
|
-
configureOrm({
|
|
35
|
-
repositoryConfig: { schema },
|
|
36
|
-
connection: {
|
|
37
|
-
host: '127.0.0.1', port: 5432, user: 'tstdl', password: 'wf7rq6glrk5jykne', database: 'tstdl',
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
db = injector.resolve(Database);
|
|
30
|
+
test.beforeEach(async ({ database: db }) => {
|
|
41
31
|
await db.execute(sql `CREATE SCHEMA IF NOT EXISTS ${sql.identifier(schema)}`);
|
|
42
32
|
await db.execute(sql `DROP TABLE IF EXISTS ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
43
33
|
await db.execute(sql `
|
|
@@ -52,113 +42,121 @@ describe('ORM Repository Transactions Nested (Integration)', () => {
|
|
|
52
42
|
)
|
|
53
43
|
`);
|
|
54
44
|
});
|
|
55
|
-
test('should commit nested transaction', async () => {
|
|
45
|
+
test('should commit nested transaction', async ({ database: db }) => {
|
|
46
|
+
const repo = injectRepository(TxEntity);
|
|
47
|
+
await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
48
|
+
await repo.transaction(async (tx1) => {
|
|
49
|
+
const repo1 = repo.withTransaction(tx1);
|
|
50
|
+
await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
|
|
51
|
+
await repo1.transaction(async (tx2) => {
|
|
52
|
+
const repo2 = repo1.withTransaction(tx2);
|
|
53
|
+
await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
const count = await repo.count();
|
|
57
|
+
expect(count).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
test('should rollback nested transaction (inner fails)', async ({ database: db }) => {
|
|
60
|
+
const repo = injectRepository(TxEntity);
|
|
56
61
|
await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
57
|
-
|
|
58
|
-
const repo = injectRepository(TxEntity);
|
|
62
|
+
try {
|
|
59
63
|
await repo.transaction(async (tx1) => {
|
|
60
64
|
const repo1 = repo.withTransaction(tx1);
|
|
61
65
|
await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
|
|
62
66
|
await repo1.transaction(async (tx2) => {
|
|
63
67
|
const repo2 = repo1.withTransaction(tx2);
|
|
64
68
|
await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
|
|
69
|
+
throw new Error('Fail Inner');
|
|
65
70
|
});
|
|
66
71
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
expect(e.message).toBe('Fail Inner');
|
|
75
|
+
}
|
|
76
|
+
const count = await repo.count();
|
|
77
|
+
expect(count).toBe(0); // Outer should rollback too if error propagates
|
|
70
78
|
});
|
|
71
|
-
test('should rollback nested transaction
|
|
79
|
+
test('should rollback nested transaction but keep outer if caught', async ({ database: db }) => {
|
|
80
|
+
const repo = injectRepository(TxEntity);
|
|
72
81
|
await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
73
|
-
await
|
|
74
|
-
const
|
|
82
|
+
await repo.transaction(async (tx1) => {
|
|
83
|
+
const repo1 = repo.withTransaction(tx1);
|
|
84
|
+
await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
|
|
75
85
|
try {
|
|
76
|
-
await
|
|
77
|
-
const
|
|
78
|
-
await
|
|
79
|
-
|
|
80
|
-
const repo2 = repo1.withTransaction(tx2);
|
|
81
|
-
await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
|
|
82
|
-
throw new Error('Fail Inner');
|
|
83
|
-
});
|
|
86
|
+
await repo1.transaction(async (tx2) => {
|
|
87
|
+
const repo2 = repo1.withTransaction(tx2);
|
|
88
|
+
await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
|
|
89
|
+
throw new Error('Fail Inner');
|
|
84
90
|
});
|
|
85
91
|
}
|
|
86
|
-
catch
|
|
87
|
-
|
|
92
|
+
catch {
|
|
93
|
+
// Swallow error to keep outer alive
|
|
88
94
|
}
|
|
89
|
-
const count = await repo.count();
|
|
90
|
-
expect(count).toBe(0); // Outer should rollback too if error propagates
|
|
91
95
|
});
|
|
96
|
+
const all = await repo.loadAll();
|
|
97
|
+
expect(all).toHaveLength(1);
|
|
98
|
+
expect(all[0].name).toBe('Outer');
|
|
92
99
|
});
|
|
93
|
-
test('should rollback
|
|
100
|
+
test('should rollback outer transaction (outer fails after inner commit)', async ({ database: db }) => {
|
|
101
|
+
const repo = injectRepository(TxEntity);
|
|
94
102
|
await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
95
|
-
|
|
96
|
-
const repo = injectRepository(TxEntity);
|
|
103
|
+
try {
|
|
97
104
|
await repo.transaction(async (tx1) => {
|
|
98
105
|
const repo1 = repo.withTransaction(tx1);
|
|
99
106
|
await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
// Swallow error to keep outer alive
|
|
109
|
-
}
|
|
107
|
+
await repo1.transaction(async (tx2) => {
|
|
108
|
+
const repo2 = repo1.withTransaction(tx2);
|
|
109
|
+
await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
|
|
110
|
+
});
|
|
111
|
+
throw new Error('Fail Outer');
|
|
110
112
|
});
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Expected
|
|
116
|
+
}
|
|
117
|
+
const count = await repo.count();
|
|
118
|
+
expect(count).toBe(0);
|
|
115
119
|
});
|
|
116
|
-
test('should
|
|
120
|
+
test('should support multiple nested transactions sequentially', async ({ database: db }) => {
|
|
121
|
+
const repo = injectRepository(TxEntity);
|
|
117
122
|
await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
118
|
-
await
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
await
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
|
|
127
|
-
});
|
|
128
|
-
throw new Error('Fail Outer');
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
// Expected
|
|
133
|
-
}
|
|
134
|
-
const count = await repo.count();
|
|
135
|
-
expect(count).toBe(0);
|
|
123
|
+
await repo.transaction(async (tx1) => {
|
|
124
|
+
const repo1 = repo.withTransaction(tx1);
|
|
125
|
+
await repo1.transaction(async (tx2) => {
|
|
126
|
+
await repo1.withTransaction(tx2).insert(Object.assign(new TxEntity(), { name: 'Inner1' }));
|
|
127
|
+
});
|
|
128
|
+
await repo1.transaction(async (tx3) => {
|
|
129
|
+
await repo1.withTransaction(tx3).insert(Object.assign(new TxEntity(), { name: 'Inner2' }));
|
|
130
|
+
});
|
|
136
131
|
});
|
|
132
|
+
const count = await repo.count();
|
|
133
|
+
expect(count).toBe(2);
|
|
137
134
|
});
|
|
138
|
-
test('should support
|
|
135
|
+
test('should support deep nesting (3 levels)', async ({ database: db }) => {
|
|
136
|
+
const repo = injectRepository(TxEntity);
|
|
139
137
|
await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
140
|
-
await
|
|
141
|
-
const
|
|
142
|
-
await
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await
|
|
138
|
+
await repo.transaction(async (tx1) => {
|
|
139
|
+
const r1 = repo.withTransaction(tx1);
|
|
140
|
+
await r1.insert(Object.assign(new TxEntity(), { name: 'L1' }));
|
|
141
|
+
await r1.transaction(async (tx2) => {
|
|
142
|
+
const r2 = r1.withTransaction(tx2);
|
|
143
|
+
await r2.insert(Object.assign(new TxEntity(), { name: 'L2' }));
|
|
144
|
+
await r2.transaction(async (tx3) => {
|
|
145
|
+
const r3 = r2.withTransaction(tx3);
|
|
146
|
+
await r3.insert(Object.assign(new TxEntity(), { name: 'L3' }));
|
|
149
147
|
});
|
|
150
148
|
});
|
|
151
|
-
const count = await repo.count();
|
|
152
|
-
expect(count).toBe(2);
|
|
153
149
|
});
|
|
150
|
+
const count = await repo.count();
|
|
151
|
+
expect(count).toBe(3);
|
|
154
152
|
});
|
|
155
|
-
test('should
|
|
153
|
+
test('should rollback deep nesting middle layer', async ({ database: db }) => {
|
|
154
|
+
const repo = injectRepository(TxEntity);
|
|
156
155
|
await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
157
|
-
await
|
|
158
|
-
const
|
|
159
|
-
await
|
|
160
|
-
|
|
161
|
-
await r1.insert(Object.assign(new TxEntity(), { name: 'L1' }));
|
|
156
|
+
await repo.transaction(async (tx1) => {
|
|
157
|
+
const r1 = repo.withTransaction(tx1);
|
|
158
|
+
await r1.insert(Object.assign(new TxEntity(), { name: 'L1' }));
|
|
159
|
+
try {
|
|
162
160
|
await r1.transaction(async (tx2) => {
|
|
163
161
|
const r2 = r1.withTransaction(tx2);
|
|
164
162
|
await r2.insert(Object.assign(new TxEntity(), { name: 'L2' }));
|
|
@@ -166,37 +164,15 @@ describe('ORM Repository Transactions Nested (Integration)', () => {
|
|
|
166
164
|
const r3 = r2.withTransaction(tx3);
|
|
167
165
|
await r3.insert(Object.assign(new TxEntity(), { name: 'L3' }));
|
|
168
166
|
});
|
|
167
|
+
throw new Error('Fail L2');
|
|
169
168
|
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
});
|
|
175
|
-
test('should rollback deep nesting middle layer', async () => {
|
|
176
|
-
await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
|
|
177
|
-
await runInInjectionContext(injector, async () => {
|
|
178
|
-
const repo = injectRepository(TxEntity);
|
|
179
|
-
await repo.transaction(async (tx1) => {
|
|
180
|
-
const r1 = repo.withTransaction(tx1);
|
|
181
|
-
await r1.insert(Object.assign(new TxEntity(), { name: 'L1' }));
|
|
182
|
-
try {
|
|
183
|
-
await r1.transaction(async (tx2) => {
|
|
184
|
-
const r2 = r1.withTransaction(tx2);
|
|
185
|
-
await r2.insert(Object.assign(new TxEntity(), { name: 'L2' }));
|
|
186
|
-
await r2.transaction(async (tx3) => {
|
|
187
|
-
const r3 = r2.withTransaction(tx3);
|
|
188
|
-
await r3.insert(Object.assign(new TxEntity(), { name: 'L3' }));
|
|
189
|
-
});
|
|
190
|
-
throw new Error('Fail L2');
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
catch {
|
|
194
|
-
// Catch L2 failure
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
|
-
const all = await repo.loadAll();
|
|
198
|
-
expect(all).toHaveLength(1);
|
|
199
|
-
expect(all[0].name).toBe('L1');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Catch L2 failure
|
|
172
|
+
}
|
|
200
173
|
});
|
|
174
|
+
const all = await repo.loadAll();
|
|
175
|
+
expect(all).toHaveLength(1);
|
|
176
|
+
expect(all[0].name).toBe('L1');
|
|
201
177
|
});
|
|
202
178
|
});
|
|
@@ -10,10 +10,11 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
10
10
|
import { sql } from 'drizzle-orm';
|
|
11
11
|
import { Injector, runInInjectionContext, Singleton } from '../../injector/index.js';
|
|
12
12
|
import { StringProperty } from '../../schema/index.js';
|
|
13
|
+
import { setupIntegrationTest } from '../../testing/index.js';
|
|
13
14
|
import { beforeAll, describe, expect, test } from 'vitest';
|
|
14
15
|
import { Table } from '../decorators.js';
|
|
15
16
|
import { Entity } from '../entity.js';
|
|
16
|
-
import {
|
|
17
|
+
import { Database } from '../server/index.js';
|
|
17
18
|
import { getRepository } from '../server/repository.js';
|
|
18
19
|
import { Transactional } from '../server/transactional.js';
|
|
19
20
|
describe('ORM Transactional (Integration)', () => {
|
|
@@ -47,20 +48,10 @@ describe('ORM Transactional (Integration)', () => {
|
|
|
47
48
|
Singleton()
|
|
48
49
|
], TransactionalService);
|
|
49
50
|
beforeAll(async () => {
|
|
50
|
-
injector = new Injector('Test');
|
|
51
51
|
const encryptionSecret = new Uint8Array(32).fill(1);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
host: '127.0.0.1',
|
|
56
|
-
port: 5432,
|
|
57
|
-
user: 'tstdl',
|
|
58
|
-
password: 'wf7rq6glrk5jykne',
|
|
59
|
-
database: 'tstdl',
|
|
60
|
-
},
|
|
61
|
-
encryptionSecret,
|
|
62
|
-
});
|
|
63
|
-
db = injector.resolve(Database);
|
|
52
|
+
const context = await setupIntegrationTest({ orm: { schema, encryptionSecret } });
|
|
53
|
+
injector = context.injector;
|
|
54
|
+
db = context.database;
|
|
64
55
|
await db.execute(sql `CREATE SCHEMA IF NOT EXISTS ${sql.identifier(schema)}`);
|
|
65
56
|
await db.execute(sql `DROP TABLE IF EXISTS ${sql.identifier(schema)}.${sql.identifier('transactional_items')} CASCADE`);
|
|
66
57
|
await db.execute(sql `
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tstdl/base",
|
|
3
|
-
"version": "0.93.
|
|
3
|
+
"version": "0.93.165",
|
|
4
4
|
"author": "Patrick Hein",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -152,9 +152,9 @@
|
|
|
152
152
|
"type-fest": "^5.4"
|
|
153
153
|
},
|
|
154
154
|
"peerDependencies": {
|
|
155
|
-
"@aws-sdk/client-s3": "^3.
|
|
156
|
-
"@aws-sdk/s3-request-presigner": "^3.
|
|
157
|
-
"@genkit-ai/google-genai": "^1.
|
|
155
|
+
"@aws-sdk/client-s3": "^3.1010",
|
|
156
|
+
"@aws-sdk/s3-request-presigner": "^3.1010",
|
|
157
|
+
"@genkit-ai/google-genai": "^1.30",
|
|
158
158
|
"@google-cloud/storage": "^7.19",
|
|
159
159
|
"@toon-format/toon": "^2.1.0",
|
|
160
160
|
"@tstdl/angular": "^0.93",
|
|
@@ -164,16 +164,16 @@
|
|
|
164
164
|
"@zxcvbn-ts/language-en": "^3.0",
|
|
165
165
|
"drizzle-orm": "^0.45",
|
|
166
166
|
"file-type": "^21.3",
|
|
167
|
-
"genkit": "^1.
|
|
167
|
+
"genkit": "^1.30",
|
|
168
168
|
"handlebars": "^4.7",
|
|
169
169
|
"mjml": "^4.18",
|
|
170
170
|
"nodemailer": "^8.0",
|
|
171
171
|
"pg": "^8.20",
|
|
172
172
|
"playwright": "^1.58",
|
|
173
|
-
"preact": "^10.
|
|
173
|
+
"preact": "^10.29",
|
|
174
174
|
"preact-render-to-string": "^6.6",
|
|
175
175
|
"sharp": "^0.34",
|
|
176
|
-
"undici": "^7.
|
|
176
|
+
"undici": "^7.24",
|
|
177
177
|
"urlpattern-polyfill": "^10.1",
|
|
178
178
|
"zod": "^3.25"
|
|
179
179
|
},
|
|
@@ -191,8 +191,8 @@
|
|
|
191
191
|
"@types/node": "25",
|
|
192
192
|
"@types/nodemailer": "7.0",
|
|
193
193
|
"@types/pg": "8.18",
|
|
194
|
-
"@vitest/coverage-v8": "4.
|
|
195
|
-
"@vitest/ui": "4.
|
|
194
|
+
"@vitest/coverage-v8": "4.1",
|
|
195
|
+
"@vitest/ui": "4.1",
|
|
196
196
|
"concurrently": "9.2",
|
|
197
197
|
"drizzle-kit": "0.31",
|
|
198
198
|
"eslint": "9.39",
|
|
@@ -202,9 +202,9 @@
|
|
|
202
202
|
"typedoc-plugin-markdown": "4.10",
|
|
203
203
|
"typedoc-plugin-missing-exports": "4.1",
|
|
204
204
|
"typescript": "5.9",
|
|
205
|
-
"typescript-eslint": "8.
|
|
205
|
+
"typescript-eslint": "8.57",
|
|
206
206
|
"vite-tsconfig-paths": "6.1",
|
|
207
|
-
"vitest": "4.
|
|
207
|
+
"vitest": "4.1"
|
|
208
208
|
},
|
|
209
209
|
"overrides": {
|
|
210
210
|
"drizzle-kit": {
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
-
import {
|
|
2
|
+
import { describe, expect, vi } from 'vitest';
|
|
3
3
|
import { inject } from '../../injector/index.js';
|
|
4
4
|
import { TRANSACTION_TIMESTAMP } from '../../orm/index.js';
|
|
5
5
|
import { injectRepository } from '../../orm/server/index.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getIntegrationTest } from '../../testing/index.js';
|
|
7
7
|
import { timeout } from '../../utils/timing.js';
|
|
8
8
|
import { taskArchive as taskArchiveTable, taskDependency as taskDependencyTable, task as taskTable } from '../postgres/schemas.js';
|
|
9
9
|
import { PostgresTaskQueue } from '../postgres/task-queue.js';
|
|
10
10
|
import { PostgresTask, PostgresTaskArchive } from '../postgres/task.model.js';
|
|
11
11
|
import { TaskDependencyType, TaskStatus } from '../task-queue.js';
|
|
12
|
+
const test = getIntegrationTest({ modules: { taskQueue: true } });
|
|
12
13
|
describe('Task Queue Optimization Edge Cases', () => {
|
|
13
|
-
|
|
14
|
-
beforeAll(async () => {
|
|
15
|
-
context = await setupIntegrationTest({ modules: { taskQueue: true } });
|
|
16
|
-
});
|
|
17
|
-
testInInjector('should notify unique namespaces exactly once in incrementCounters', () => context.injector, async () => {
|
|
14
|
+
test('should notify unique namespaces exactly once in incrementCounters', async () => {
|
|
18
15
|
const q1 = inject(PostgresTaskQueue, 'ns-1');
|
|
19
16
|
const q2 = inject(PostgresTaskQueue, 'ns-2');
|
|
20
17
|
const notifySpy = vi.spyOn(PostgresTaskQueue.prototype, 'notify');
|
|
@@ -32,7 +29,7 @@ describe('Task Queue Optimization Edge Cases', () => {
|
|
|
32
29
|
expect(notifiedNamespaces.filter(n => n == 'ns-1').length).toBe(1);
|
|
33
30
|
expect(notifiedNamespaces.filter(n => n == 'ns-2').length).toBe(1);
|
|
34
31
|
});
|
|
35
|
-
|
|
32
|
+
test('should resolve edge and abort on unmatched terminal status in resolveDependenciesMany', async () => {
|
|
36
33
|
const queue = inject(PostgresTaskQueue, 'test-namespace');
|
|
37
34
|
const repository = injectRepository(PostgresTask);
|
|
38
35
|
const [parent] = await queue.enqueueMany([{ type: 'parent', data: {}, abortOnDependencyFailure: true }], { returnTasks: true });
|
|
@@ -48,7 +45,7 @@ describe('Task Queue Optimization Edge Cases', () => {
|
|
|
48
45
|
const updatedParent = await queue.getTask(parent.id);
|
|
49
46
|
expect(updatedParent.status).toBe(TaskStatus.Skipped);
|
|
50
47
|
});
|
|
51
|
-
|
|
48
|
+
test('should handle hard timeout during touch', async () => {
|
|
52
49
|
// Configure with small maxExecutionTime
|
|
53
50
|
const queue = inject(PostgresTaskQueue, { namespace: 'timeout-test', maxExecutionTime: 10 });
|
|
54
51
|
const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
|
|
@@ -59,18 +56,18 @@ describe('Task Queue Optimization Edge Cases', () => {
|
|
|
59
56
|
const updated = await queue.getTask(runningTask.id);
|
|
60
57
|
expect(updated.status).toBe(TaskStatus.TimedOut);
|
|
61
58
|
});
|
|
62
|
-
|
|
59
|
+
test('should handle non-existent tasks in complete and fail', async () => {
|
|
63
60
|
const queue = inject(PostgresTaskQueue, 'missing-test');
|
|
64
61
|
const fakeTask = { id: crypto.randomUUID(), token: crypto.randomUUID(), tries: 0 };
|
|
65
62
|
await expect(queue.complete(fakeTask)).resolves.toBeUndefined();
|
|
66
63
|
await expect(queue.fail(fakeTask, new Error('fail'))).resolves.toBeUndefined();
|
|
67
64
|
});
|
|
68
|
-
|
|
65
|
+
test('should handle terminal tasks with no dependents in resolveDependenciesMany', async () => {
|
|
69
66
|
const queue = inject(PostgresTaskQueue, 'no-deps-test');
|
|
70
67
|
const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
|
|
71
68
|
await queue.resolveDependenciesMany([{ id: task.id, status: TaskStatus.Completed }]);
|
|
72
69
|
});
|
|
73
|
-
|
|
70
|
+
test('should handle archival and purge in maintenance', async () => {
|
|
74
71
|
const namespace = `archival-test-${crypto.randomUUID()}`;
|
|
75
72
|
// Configure with small retention
|
|
76
73
|
const queue = inject(PostgresTaskQueue, { namespace, retention: 1000, archiveRetention: 1000 });
|
|
@@ -93,7 +90,7 @@ describe('Task Queue Optimization Edge Cases', () => {
|
|
|
93
90
|
const purged = await archiveRepository.load(task.id).catch(() => undefined);
|
|
94
91
|
expect(purged).toBeUndefined();
|
|
95
92
|
});
|
|
96
|
-
|
|
93
|
+
test('should notify on restart', async () => {
|
|
97
94
|
const namespace = `restart-test-${crypto.randomUUID()}`;
|
|
98
95
|
const queue = inject(PostgresTaskQueue, namespace);
|
|
99
96
|
const notifySpy = vi.spyOn(PostgresTaskQueue.prototype, 'notify');
|
|
@@ -103,7 +100,7 @@ describe('Task Queue Optimization Edge Cases', () => {
|
|
|
103
100
|
await queue.restart(task.id);
|
|
104
101
|
expect(notifySpy).toHaveBeenCalledWith(namespace);
|
|
105
102
|
});
|
|
106
|
-
|
|
103
|
+
test('should use exponential backoff for zombies', async () => {
|
|
107
104
|
const namespace = `zombie-backoff-${crypto.randomUUID()}`;
|
|
108
105
|
// Configure with standard growth
|
|
109
106
|
const queue = inject(PostgresTaskQueue, { namespace, retryDelayMinimum: 1000, retryDelayGrowth: 2 });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { eq, or } from 'drizzle-orm';
|
|
2
2
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
3
3
|
import { CancellationToken } from '../../cancellation/index.js';
|
|
4
|
+
import { CircuitBreaker, CircuitBreakerState } from '../../circuit-breaker/index.js';
|
|
4
5
|
import { Database } from '../../orm/server/index.js';
|
|
5
6
|
import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
|
|
6
7
|
import { task as taskTable } from '../../task-queue/postgres/schemas.js';
|
|
@@ -356,7 +357,7 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
356
357
|
const t3Attempt = await queue.dequeue();
|
|
357
358
|
expect(t3Attempt).toBeUndefined();
|
|
358
359
|
});
|
|
359
|
-
it('should allow a single
|
|
360
|
+
it('should only allow a single active task in Half-Open state', async () => {
|
|
360
361
|
await queue.enqueueMany([
|
|
361
362
|
{ type: 'foo', data: { foo: '1' } },
|
|
362
363
|
{ type: 'foo', data: { foo: '2' } },
|
|
@@ -368,7 +369,33 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
368
369
|
const probe = await queue.dequeue();
|
|
369
370
|
expect(probe).toBeDefined();
|
|
370
371
|
const secondAttempt = await queue.dequeue();
|
|
371
|
-
expect(secondAttempt).toBeUndefined(); // Only 1
|
|
372
|
+
expect(secondAttempt).toBeUndefined(); // Only 1 task allowed to RUN in Half-Open
|
|
373
|
+
});
|
|
374
|
+
it('should not stall forever when circuit breaker is Half-Open and no tasks are found', async () => {
|
|
375
|
+
// 1. Trip the breaker
|
|
376
|
+
await queue.enqueue('foo', { foo: '1' });
|
|
377
|
+
await queue.enqueue('foo', { foo: '2' });
|
|
378
|
+
await queue.fail((await queue.dequeue()), 'err', { fatal: true });
|
|
379
|
+
await queue.fail((await queue.dequeue()), 'err', { fatal: true });
|
|
380
|
+
const cb = injector.resolve(CircuitBreaker, queue.namespace);
|
|
381
|
+
let status = await cb.check();
|
|
382
|
+
expect(status.state).toBe(CircuitBreakerState.Open);
|
|
383
|
+
expect(status.allowed).toBe(false);
|
|
384
|
+
// 2. Wait for timeout (50ms)
|
|
385
|
+
await timeout(75);
|
|
386
|
+
// 3. First dequeue - should be the probe, but find nothing (we enqueued and failed the only task)
|
|
387
|
+
const tasks1 = await queue.dequeueMany(1);
|
|
388
|
+
expect(tasks1).toHaveLength(0);
|
|
389
|
+
status = await cb.check();
|
|
390
|
+
expect(status.state).toBe(CircuitBreakerState.HalfOpen);
|
|
391
|
+
// 4. Second dequeue - should still be allowed (but find nothing)
|
|
392
|
+
const tasks2 = await queue.dequeueMany(1);
|
|
393
|
+
expect(tasks2).toHaveLength(0);
|
|
394
|
+
// 5. Add a task
|
|
395
|
+
await queue.enqueue('foo', { foo: 'new' });
|
|
396
|
+
// 6. Third dequeue - should find the task now!
|
|
397
|
+
const tasks3 = await queue.dequeueMany(1);
|
|
398
|
+
expect(tasks3).toHaveLength(1);
|
|
372
399
|
});
|
|
373
400
|
});
|
|
374
401
|
describe('Timeouts and Maintenance (Pruning)', () => {
|
package/testing/README.md
CHANGED
|
@@ -80,51 +80,40 @@ The `setupIntegrationTest` utility (found in `source/testing/integration-setup.t
|
|
|
80
80
|
4. **Schema Isolation**: Automatically creates and uses PostgreSQL schemas for isolation.
|
|
81
81
|
5. **Modules**: Optional configuration for `taskQueue`, `authentication`, `objectStorage`, `notification`, etc.
|
|
82
82
|
|
|
83
|
-
### Injection Context: `
|
|
83
|
+
### Injection Context: `getIntegrationTest`
|
|
84
84
|
|
|
85
|
-
Integration tests
|
|
86
|
-
|
|
87
|
-
> [!IMPORTANT]
|
|
88
|
-
> `testInInjector` is **only required** for code that uses `inject()` or `injectRepository()` outside of a class constructor/initializer (e.g. ad-hoc repository injection in a test body).
|
|
89
|
-
>
|
|
90
|
-
> If you resolve your services in `beforeAll` or `beforeEach`, calling their methods usually does **not** require `testInInjector`.
|
|
91
|
-
|
|
92
|
-
> [!TIP]
|
|
93
|
-
> **Performance & Reusability**: For most integration tests, resolve services once in `beforeAll` and reuse the variables in your tests.
|
|
85
|
+
Integration tests often require an injection context to resolve services and repositories using `inject()`. You can use the `getIntegrationTest` helper to create a specialized `test` function that automatically manages this context and simplifies setup.
|
|
94
86
|
|
|
95
87
|
```typescript
|
|
96
|
-
import {
|
|
97
|
-
import {
|
|
88
|
+
import { describe, expect } from 'vitest';
|
|
89
|
+
import { inject } from '#/injector/index.js';
|
|
90
|
+
import { getIntegrationTest } from '#/testing/index.js';
|
|
98
91
|
import { MyService } from '../my-service.js';
|
|
99
92
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
beforeAll(async () => {
|
|
105
|
-
({ injector } = await setupIntegrationTest({
|
|
106
|
-
modules: { taskQueue: true },
|
|
107
|
-
}));
|
|
108
|
-
|
|
109
|
-
// Resolve reused services once
|
|
110
|
-
myService = injector.resolve(MyService);
|
|
111
|
-
});
|
|
93
|
+
// 1. Create a specialized test function with your required modules
|
|
94
|
+
const test = getIntegrationTest({
|
|
95
|
+
modules: { taskQueue: true },
|
|
96
|
+
});
|
|
112
97
|
|
|
113
|
-
|
|
98
|
+
describe('MyService Integration', () => {
|
|
99
|
+
// 2. Use the returned 'test' function - it automatically runs in an injection context
|
|
114
100
|
test('should process data', async () => {
|
|
101
|
+
// You can use inject() directly in the test body!
|
|
102
|
+
const myService = inject(MyService);
|
|
115
103
|
const result = await myService.process();
|
|
104
|
+
|
|
116
105
|
expect(result.success).toBe(true);
|
|
117
106
|
});
|
|
118
|
-
|
|
119
|
-
// testInInjector is ONLY needed if you use inject() directly in the test body
|
|
120
|
-
testInInjector('should work with ad-hoc injection', () => injector, async () => {
|
|
121
|
-
const repo = injectRepository(SomeEntity);
|
|
122
|
-
// ...
|
|
123
|
-
},
|
|
124
|
-
);
|
|
125
107
|
});
|
|
126
108
|
```
|
|
127
109
|
|
|
110
|
+
> [!IMPORTANT]
|
|
111
|
+
> **Async Context Limitation**: Due to how asynchronous execution works in Node.js, `inject()` calls **must** occur before any `await` statements in your test function. If you need to inject something after an `await`, resolve it at the beginning of the test.
|
|
112
|
+
|
|
113
|
+
> [!TIP]
|
|
114
|
+
> **Fixtures**: The `test` function provided by `getIntegrationTest` is an extended Vitest test. You can access `injector`, `database`, and `context` as fixtures if needed:
|
|
115
|
+
> `test('example', async ({ injector, database }) => { ... })`
|
|
116
|
+
|
|
128
117
|
## Best Practices & Guidelines
|
|
129
118
|
|
|
130
119
|
### 1. Isolation & Performance
|
|
@@ -154,7 +143,7 @@ describe('MyService Integration', () => {
|
|
|
154
143
|
injector.register(MailService, { useValue: mailServiceMock });
|
|
155
144
|
```
|
|
156
145
|
- **Injection Context**: Any code that uses repositories or services relying on `inject()` outside of a class constructor/initializer **must** be wrapped in an injection context.
|
|
157
|
-
- **Preferred**: Use
|
|
146
|
+
- **Preferred**: Use the test function returned by `getIntegrationTest`.
|
|
158
147
|
- **Alternative**: Use `runInInjectionContext(injector, async () => { ... })` for specific blocks within a test or hook.
|
|
159
148
|
- **Mock Reset**: Always call `vi.clearAllMocks()` in `beforeEach` if using spys or mocks to ensure test independence.
|
|
160
149
|
|
|
@@ -183,8 +172,7 @@ describe('MyService Integration', () => {
|
|
|
183
172
|
### 5. Common Helpers (from `source/testing/`)
|
|
184
173
|
|
|
185
174
|
- `setupIntegrationTest(options)`: Main entry point for integration tests.
|
|
186
|
-
- `
|
|
187
|
-
- `itInInjector`: Alias for `testInInjector`.
|
|
175
|
+
- `getIntegrationTest(options)`: Returns a Vitest test function pre-configured with the given options and an injection context.
|
|
188
176
|
- `truncateTables(database, schema, tables)`: Clears data from specified tables.
|
|
189
177
|
- `dropTables(database, schema, tables)`: Drops specified tables.
|
|
190
178
|
- `clearTenantData(database, schema, tables, tenantId)`: Deletes data for a specific tenant.
|