@tstdl/base 0.93.145 → 0.93.147

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 (44) hide show
  1. package/authentication/tests/authentication.client-service.test.js +15 -19
  2. package/authentication/tests/authentication.service.test.js +92 -119
  3. package/notification/tests/notification-client.test.js +39 -50
  4. package/notification/tests/notification-flow.test.js +204 -238
  5. package/notification/tests/notification-sse.service.test.js +20 -27
  6. package/notification/tests/notification-type.service.test.js +17 -20
  7. package/orm/repository.types.d.ts +13 -2
  8. package/orm/server/repository.d.ts +60 -4
  9. package/orm/server/repository.js +126 -25
  10. package/orm/tests/query-complex.test.js +80 -111
  11. package/orm/tests/repository-advanced.test.js +100 -143
  12. package/orm/tests/repository-attributes.test.js +30 -39
  13. package/orm/tests/repository-compound-primary-key.test.js +67 -75
  14. package/orm/tests/repository-comprehensive.test.js +76 -101
  15. package/orm/tests/repository-coverage.test.d.ts +1 -0
  16. package/orm/tests/repository-coverage.test.js +88 -149
  17. package/orm/tests/repository-cti-extensive.test.d.ts +1 -0
  18. package/orm/tests/repository-cti-extensive.test.js +118 -147
  19. package/orm/tests/repository-cti-mapping.test.d.ts +1 -0
  20. package/orm/tests/repository-cti-mapping.test.js +29 -42
  21. package/orm/tests/repository-cti-soft-delete.test.d.ts +1 -0
  22. package/orm/tests/repository-cti-soft-delete.test.js +25 -37
  23. package/orm/tests/repository-cti-transactions.test.js +19 -33
  24. package/orm/tests/repository-cti-upsert-many.test.d.ts +1 -0
  25. package/orm/tests/repository-cti-upsert-many.test.js +38 -50
  26. package/orm/tests/repository-cti.test.d.ts +1 -0
  27. package/orm/tests/repository-cti.test.js +195 -247
  28. package/orm/tests/repository-expiration.test.d.ts +1 -0
  29. package/orm/tests/repository-expiration.test.js +46 -59
  30. package/orm/tests/repository-extra-coverage.test.d.ts +1 -0
  31. package/orm/tests/repository-extra-coverage.test.js +195 -337
  32. package/orm/tests/repository-mapping.test.d.ts +1 -0
  33. package/orm/tests/repository-mapping.test.js +20 -20
  34. package/orm/tests/repository-regression.test.js +124 -163
  35. package/orm/tests/repository-search.test.js +30 -44
  36. package/orm/tests/repository-soft-delete.test.js +54 -79
  37. package/orm/tests/repository-types.test.js +77 -111
  38. package/orm/tests/repository-undelete.test.d.ts +2 -0
  39. package/orm/tests/repository-undelete.test.js +201 -0
  40. package/package.json +3 -3
  41. package/task-queue/tests/worker.test.js +5 -5
  42. package/testing/README.md +38 -16
  43. package/testing/integration-setup.d.ts +11 -0
  44. package/testing/integration-setup.js +57 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.145",
3
+ "version": "0.93.147",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -152,8 +152,8 @@
152
152
  "type-fest": "^5.4"
153
153
  },
154
154
  "peerDependencies": {
155
- "@aws-sdk/client-s3": "^3.999",
156
- "@aws-sdk/s3-request-presigner": "^3.999",
155
+ "@aws-sdk/client-s3": "^3.1000",
156
+ "@aws-sdk/s3-request-presigner": "^3.1000",
157
157
  "@genkit-ai/google-genai": "^1.29",
158
158
  "@google-cloud/storage": "^7.19",
159
159
  "@toon-format/toon": "^2.1.0",
@@ -1,7 +1,6 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
2
  import { CancellationToken } from '../../cancellation/index.js';
3
- import { runInInjectionContext } from '../../injector/index.js';
4
- import { injectRepository } from '../../orm/server/index.js';
3
+ import { getRepository } from '../../orm/server/index.js';
5
4
  import { TaskProcessResult, TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
6
5
  import { PostgresTask } from '../../task-queue/postgres/index.js';
7
6
  import { setupIntegrationTest } from '../../testing/index.js';
@@ -11,8 +10,10 @@ describe('Worker & Base Class Tests', () => {
11
10
  let queue;
12
11
  let token;
13
12
  let otherQueueName;
13
+ let taskRepository;
14
14
  beforeAll(async () => {
15
15
  ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
16
+ taskRepository = injector.resolve(getRepository(PostgresTask));
16
17
  otherQueueName = `other-queue-${crypto.randomUUID()}`;
17
18
  });
18
19
  beforeEach(() => {
@@ -25,11 +26,10 @@ describe('Worker & Base Class Tests', () => {
25
26
  });
26
27
  afterEach(async () => {
27
28
  token.set();
28
- const repository = runInInjectionContext(injector, () => injectRepository(PostgresTask));
29
29
  const namespace = queue.getTransactionalContextData().namespace;
30
30
  // Clear foreign keys
31
- await repository.updateManyByQuery({ namespace }, { parentId: null });
32
- await repository.updateManyByQuery({ namespace: otherQueueName }, { parentId: null });
31
+ await taskRepository.updateManyByQuery({ namespace }, { parentId: null });
32
+ await taskRepository.updateManyByQuery({ namespace: otherQueueName }, { parentId: null });
33
33
  await queue.clear();
34
34
  const queueProvider = injector.resolve(TaskQueueProvider);
35
35
  await queueProvider.get(otherQueueName).clear();
package/testing/README.md CHANGED
@@ -80,11 +80,16 @@ 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
- ### Integration Test Example
83
+ Integration tests typically require an injection context to resolve services and repositories. You can use the `testInInjector` (or `itInInjector`) helper to automatically wrap your test body.
84
+
85
+ > [!IMPORTANT]
86
+ > `testInInjector` is designed for test files where a **single injector** is shared across all tests (typically initialized in `beforeAll`). It accepts a `ValueOrProvider<Injector>`, allowing you to pass a getter function (e.g., `() => injector`) if the injector variable is initialized late.
87
+ >
88
+ > If your tests require a fresh injector per test case (e.g. `setupIntegrationTest` in `beforeEach`), you can still use `testInInjector` by passing a provider.
84
89
 
85
90
  ```typescript
86
- import { beforeAll, describe, expect, it } from 'vitest';
87
- import { setupIntegrationTest } from '#/testing/index.js';
91
+ import { beforeAll, describe, expect } from 'vitest';
92
+ import { setupIntegrationTest, testInInjector } from '#/testing/index.js';
88
93
  import { MyService } from '../my-service.js';
89
94
 
90
95
  describe('MyService Integration', () => {
@@ -98,19 +103,32 @@ describe('MyService Integration', () => {
98
103
  }));
99
104
  });
100
105
 
101
- it('should process data through the database', async () => {
106
+ testInInjector('should process data through the database', injector, async () => {
102
107
  const service = injector.resolve(MyService);
103
108
  const result = await service.process();
104
109
  expect(result.success).toBe(true);
105
110
  });
111
+
112
+ // Using a provider function to defer resolution of the 'injector' variable when using `beforeEach` for injector setup
113
+ testInInjector('should process data through the database', () => injector, async () => { ... });
106
114
  });
107
115
  ```
108
116
 
109
117
  ## Best Practices & Guidelines
110
118
 
111
- ### 1. Isolation
112
-
113
- - **Schemas**: Always use a unique PostgreSQL schema for your integration test suite via `orm: { schema: '...' }`.
119
+ ### 1. Isolation & Performance
120
+
121
+ - **`beforeAll` vs `beforeEach`**:
122
+ - **Use `beforeAll` (Recommended)**: For most integration tests. It's significantly faster as it creates the schema and initializes the injector once.
123
+ - **Use `beforeEach`**: Use this only when:
124
+ - You need a completely clean Injector state for every test (e.g. if singletons are being mutated and cannot be reset).
125
+ - You are testing the initialization or disposal logic of your services.
126
+ - Each test requires a different module configuration in `setupIntegrationTest`.
127
+ - **Data Reset**:
128
+ - **`clearTenantData` (Best for Parallelism)**: If your tests use random `tenantId`s, use this in `afterEach` or `beforeEach`. It allows multiple tests within the same schema to run without interfering with each other's data.
129
+ - **`truncateTables` (Use with Caution)**: Only suitable if you are certain that no other tests are concurrently using the same database tables (even with different tenant IDs), as `TRUNCATE` affects the entire table and can cause flakiness in parallel environments.
130
+ - **Unit vs. Integration**: Prefer integration tests over unit tests when reasonably possible to cover more code paths and reduce the need for extensive mocking.
131
+ - **Parallelism**: Vitest runs test files in parallel. Always use unique resource names (e.g., using `crypto.randomUUID()`) to avoid flaky tests and cross-file interference.
114
132
  - **Resource Naming**: When testing shared infrastructure (Task Queues, S3 Buckets, Rate Limiters), append a random suffix or timestamp to the resource name (e.g., `const queueName = \`test-queue-${crypto.randomUUID()}\``).
115
133
  - **Cleanup**: Use `truncateTables` or `dropTables` in `beforeAll` or `beforeEach` to ensure a clean state.
116
134
  - **Tenant Data**: Use `clearTenantData` if your tests are multi-tenant and use random tenant IDs to avoid collisions when concurrently running tests.
@@ -124,24 +142,26 @@ describe('MyService Integration', () => {
124
142
  const mailServiceMock = { send: vi.fn() };
125
143
  injector.register(MailService, { useValue: mailServiceMock });
126
144
  ```
127
- - **Injection Context**: Any code that uses repositories or services relying on `inject()` outside of a class constructor/initializer **must** be wrapped in `runInInjectionContext(injector, async () => { ... })`.
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.
146
+ - **Preferred**: Use `testInInjector` (or `itInInjector`) for the entire test body.
147
+ - **Alternative**: Use `runInInjectionContext(injector, async () => { ... })` for specific blocks within a test or hook.
128
148
  - **Mock Reset**: Always call `vi.clearAllMocks()` in `beforeEach` if using spys or mocks to ensure test independence.
129
149
 
130
150
  ### 3. Integration Test Patterns
131
151
 
132
152
  - **Schema Management**:
133
153
  - **Preferred**: Use the `modules` option in `setupIntegrationTest`. This automatically runs the necessary migrations for standard modules (e.g., `taskQueue`, `authentication`, `notification`).
134
- - **Fallback**: Use Manual DDL via `database.execute(sql\`...\`)`**only** for test-specific entities (like local`@Table`classes defined inside your test file) that are not part of a standard module. Always call`dropTables` before creating them to ensure a fresh start.
154
+ - **Fallback**: Use Manual DDL via `database.execute(sql\`...\`)` **only** for test-specific entities (like local `@Table` classes defined inside your test file) that are not part of a standard module. Always call `dropTables` before creating them to ensure a fresh start.
155
+ - **Repositories**: You can inject ad-hoc repositories inside an injection context:
156
+ ```typescript
157
+ const repo = injectRepository(MyEntity);
158
+ ```
135
159
  - **Background Workers**: When testing workers or loops, use a `CancellationToken` to stop them in `afterEach` or `afterAll`.
160
+ - **Polling & Waiting**: When testing asynchronous background effects (like a task queue processing a task), avoid arbitrary `timeout()` calls if possible. Instead, use a polling loop or wait for a specific database state.
136
161
  - **Timeouts**: Use the `timeout(ms)` helper from `#/utils/timing.js` for tests involving refills, retries, or background processing. Prefer `vi.useFakeTimers()` only when real-time passage is too slow for the test suite.
137
162
  - **Logging**: Test should generally not output logs unless for active debugging. Use try-catch for expected errors and assert on the error message instead of relying on logs.
138
163
 
139
- ### 4. Performance
140
-
141
- - Use `beforeAll` for heavy setup (like schema and table creation) and `beforeEach` for lightweight data resetting (truncation).
142
- - Prefer integration tests over unit tests when reasonably possible to cover more code paths and reduce the need for extensive mocking and stubbing.
143
-
144
- ### 5. Coding Style
164
+ ### 4. Coding Style
145
165
 
146
166
  - **Explicit Types**: Use explicit return types for methods and functions.
147
167
  - **Async/Await**: Always use `async/await` for asynchronous code to improve readability and error handling.
@@ -149,9 +169,11 @@ describe('MyService Integration', () => {
149
169
  - **Avoid `any`**: Strive to use specific types or `unknown` if required to maintain type safety.
150
170
  - **Descriptive Names**: Use descriptive names for test files, cases and variables to improve readability.
151
171
 
152
- ### 6. Common Helpers (from `source/testing/`)
172
+ ### 5. Common Helpers (from `source/testing/`)
153
173
 
154
174
  - `setupIntegrationTest(options)`: Main entry point for integration tests.
175
+ - `testInInjector(name, injector, fn, options)`: Runs a test within an injection context.
176
+ - `itInInjector`: Alias for `testInInjector`.
155
177
  - `truncateTables(database, schema, tables)`: Clears data from specified tables.
156
178
  - `dropTables(database, schema, tables)`: Drops specified tables.
157
179
  - `clearTenantData(database, schema, tables, tenantId)`: Deletes data for a specific tenant.
@@ -1,4 +1,6 @@
1
+ /** biome-ignore-all lint/nursery/useExpect: helper file */
1
2
  import type { PoolConfig } from 'pg';
3
+ import { type TestOptions } from 'vitest';
2
4
  import { type AuthenticationAncillaryService } from '../authentication/server/index.js';
3
5
  import { Injector } from '../injector/index.js';
4
6
  import { LogLevel } from '../logger/index.js';
@@ -6,6 +8,7 @@ import { type S3ObjectStorageProviderConfig } from '../object-storage/s3/index.j
6
8
  import type { EntityType } from '../orm/entity.js';
7
9
  import { Database } from '../orm/server/index.js';
8
10
  import type { Type } from '../types/index.js';
11
+ import { type ValueOrProvider } from '../utils/value-or-provider.js';
9
12
  export type IntegrationTestOptions = {
10
13
  dbConfig?: Partial<PoolConfig>;
11
14
  orm?: {
@@ -43,6 +46,14 @@ export type TestContext = {
43
46
  * Standard setup for integration tests.
44
47
  */
45
48
  export declare function setupIntegrationTest(options?: IntegrationTestOptions): Promise<TestContext>;
49
+ /**
50
+ * A wrapper for vitest's `test` that automatically runs the test function in the provided injector's context.
51
+ * @param name The name of the test.
52
+ * @param injector The injector to use for the context.
53
+ * @param fn The test function.
54
+ * @param options Vitest test options.
55
+ */
56
+ export declare function testInInjector(name: string, injector: ValueOrProvider<Injector>, fn: () => any, options?: number | TestOptions): void;
46
57
  /**
47
58
  * Helper to truncate specific tables in a schema.
48
59
  * Useful in beforeEach() to reset state.
@@ -1,5 +1,6 @@
1
+ /** biome-ignore-all lint/nursery/useExpect: helper file */
1
2
  import { sql } from 'drizzle-orm';
2
- import { afterAll } from 'vitest';
3
+ import { afterAll, test } from 'vitest';
3
4
  import { configureApiServer } from '../api/server/index.js';
4
5
  import { configureAudit } from '../audit/index.js';
5
6
  import { AuthenticationApiClient } from '../authentication/client/api.client.js';
@@ -26,7 +27,8 @@ import { configureDefaultSignalsImplementation } from '../signals/implementation
26
27
  import { configurePostgresTaskQueue } from '../task-queue/postgres/index.js';
27
28
  import * as configParser from '../utils/config-parser.js';
28
29
  import { objectEntries } from '../utils/object/object.js';
29
- import { assertDefinedPass, isDefined, isNotNull, isString } from '../utils/type-guards.js';
30
+ import { assertDefinedPass, isDefined, isNotNull, isNumber, isString, isUndefined } from '../utils/type-guards.js';
31
+ import { resolveValueOrProvider } from '../utils/value-or-provider.js';
30
32
  /**
31
33
  * Standard setup for integration tests.
32
34
  */
@@ -167,16 +169,22 @@ export async function setupIntegrationTest(options = {}) {
167
169
  return { injector, database };
168
170
  }
169
171
  /**
170
- * Helper to run a migration safely with a database advisory lock to prevent race conditions.
172
+ * A wrapper for vitest's `test` that automatically runs the test function in the provided injector's context.
173
+ * @param name The name of the test.
174
+ * @param injector The injector to use for the context.
175
+ * @param fn The test function.
176
+ * @param options Vitest test options.
171
177
  */
172
- async function runMigrationSafely(database, migration) {
173
- const lockId = 123456789; // Fixed lock ID for migrations
174
- await database.execute(sql `SELECT pg_advisory_lock(${lockId})`);
175
- try {
176
- await migration();
178
+ export function testInInjector(name, injector, fn, options) {
179
+ if (isUndefined(options) || isNumber(options)) {
180
+ test(name, async () => {
181
+ await runInInjectionContext(resolveValueOrProvider(injector), fn);
182
+ }, options);
177
183
  }
178
- finally {
179
- await database.execute(sql `SELECT pg_advisory_unlock(${lockId})`);
184
+ else {
185
+ test(name, options, async () => {
186
+ await runInInjectionContext(resolveValueOrProvider(injector), fn);
187
+ });
180
188
  }
181
189
  }
182
190
  /**
@@ -187,19 +195,15 @@ export async function truncateTables(database, schema, tables) {
187
195
  if (tables.length == 0) {
188
196
  return;
189
197
  }
190
- const lockId = 987654321; // Different lock ID for table maintenance
191
- await database.execute(sql `SELECT pg_advisory_lock(${lockId})`);
192
- try {
198
+ const lockName = `tstdl:maintenance:${schema}`;
199
+ await runWithAdvisoryLock(database, lockName, async () => {
193
200
  // Using CASCADE to handle foreign keys automatically
194
201
  for (const table of tables) {
195
202
  const tableName = isString(table) ? table : getEntityTableName(table);
196
203
  const tableSchema = isString(table) ? schema : getEntitySchema(table, schema);
197
204
  await database.execute(sql `TRUNCATE TABLE ${sql.identifier(tableSchema)}.${sql.identifier(tableName)} CASCADE`);
198
205
  }
199
- }
200
- finally {
201
- await database.execute(sql `SELECT pg_advisory_unlock(${lockId})`);
202
- }
206
+ });
203
207
  }
204
208
  /**
205
209
  * Helper to delete data for a specific tenant from specific tables.
@@ -209,18 +213,15 @@ export async function clearTenantData(database, schema, tables, tenantId) {
209
213
  if (tables.length == 0) {
210
214
  return;
211
215
  }
212
- const lockId = 987654321;
213
- await database.execute(sql `SELECT pg_advisory_lock(${lockId})`);
214
- try {
216
+ // Use a lock that is specific to both the schema AND the tenant
217
+ const lockName = `tstdl:maintenance:${schema ?? 'default'}:${tenantId}`;
218
+ await runWithAdvisoryLock(database, lockName, async () => {
215
219
  for (const table of tables) {
216
220
  const tableName = isString(table) ? table : getEntityTableName(table);
217
221
  const tableSchema = isString(table) ? assertDefinedPass(schema, 'Schema not provided') : getEntitySchema(table, schema);
218
222
  await database.execute(sql `DELETE FROM ${sql.identifier(tableSchema)}.${sql.identifier(tableName)} WHERE tenant_id = ${tenantId}`);
219
223
  }
220
- }
221
- finally {
222
- await database.execute(sql `SELECT pg_advisory_unlock(${lockId})`);
223
- }
224
+ });
224
225
  }
225
226
  /**
226
227
  * Helper to drop specific tables.
@@ -230,16 +231,42 @@ export async function dropTables(database, schema, tables) {
230
231
  if (tables.length == 0) {
231
232
  return;
232
233
  }
233
- const lockId = 987654321;
234
- await database.execute(sql `SELECT pg_advisory_lock(${lockId})`);
235
- try {
234
+ const lockName = `tstdl:maintenance:${schema ?? 'default'}`;
235
+ await runWithAdvisoryLock(database, lockName, async () => {
236
236
  for (const table of tables) {
237
237
  const tableName = isString(table) ? table : getEntityTableName(table);
238
238
  const tableSchema = isString(table) ? assertDefinedPass(schema, 'Schema not provided') : getEntitySchema(table, schema);
239
239
  await database.execute(sql `DROP TABLE IF EXISTS ${sql.identifier(tableSchema)}.${sql.identifier(tableName)} CASCADE`);
240
240
  }
241
- }
242
- finally {
243
- await database.execute(sql `SELECT pg_advisory_unlock(${lockId})`);
241
+ });
242
+ }
243
+ /**
244
+ * Helper to run a migration safely with a database advisory lock to prevent race conditions.
245
+ */
246
+ async function runMigrationSafely(database, migration) {
247
+ const lockName = 'tstdl:migration';
248
+ await runWithAdvisoryLock(database, lockName, migration);
249
+ }
250
+ async function runWithAdvisoryLock(database, lockName, fn) {
251
+ // We use pg_try_advisory_lock in a loop with small random delays to avoid deadlocks
252
+ // which sometimes happen with pg_advisory_lock under very high parallelism in Vitest
253
+ const start = Date.now();
254
+ const timeout = 30000; // 30s timeout for maintenance tasks
255
+ while (true) {
256
+ const { rows: [result] } = await database.execute(sql `SELECT pg_try_advisory_lock(hashtext(${lockName})) as locked`);
257
+ if (result?.locked) {
258
+ try {
259
+ await fn();
260
+ return;
261
+ }
262
+ finally {
263
+ await database.execute(sql `SELECT pg_advisory_unlock(hashtext(${lockName}))`);
264
+ }
265
+ }
266
+ if (Date.now() - start > timeout) {
267
+ throw new Error(`Timeout while waiting for advisory lock: ${lockName}`);
268
+ }
269
+ // Small random delay between 10ms and 100ms
270
+ await new Promise((resolve) => setTimeout(resolve, 10 + Math.random() * 90));
244
271
  }
245
272
  }