bunsane 0.2.10 → 0.3.1

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 (47) hide show
  1. package/CHANGELOG.md +318 -0
  2. package/CLAUDE.md +20 -0
  3. package/config/cache.config.ts +12 -2
  4. package/core/App.ts +300 -69
  5. package/core/ApplicationLifecycle.ts +68 -4
  6. package/core/Entity.ts +525 -256
  7. package/core/EntityHookManager.ts +88 -21
  8. package/core/EntityManager.ts +12 -3
  9. package/core/Logger.ts +4 -0
  10. package/core/RequestContext.ts +4 -1
  11. package/core/SchedulerManager.ts +105 -22
  12. package/core/cache/CacheFactory.ts +3 -1
  13. package/core/cache/CacheManager.ts +72 -17
  14. package/core/cache/RedisCache.ts +38 -3
  15. package/core/components/BaseComponent.ts +12 -2
  16. package/core/decorators/EntityHooks.ts +24 -12
  17. package/core/middleware/RateLimit.ts +105 -0
  18. package/core/middleware/index.ts +1 -0
  19. package/core/remote/OutboxWorker.ts +42 -35
  20. package/core/scheduler/DistributedLock.ts +22 -7
  21. package/database/PreparedStatementCache.ts +5 -13
  22. package/gql/builders/ResolverBuilder.ts +4 -4
  23. package/gql/complexityLimit.ts +95 -0
  24. package/gql/index.ts +15 -3
  25. package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
  26. package/package.json +1 -1
  27. package/query/ComponentInclusionNode.ts +18 -11
  28. package/query/OrNode.ts +2 -4
  29. package/query/Query.ts +42 -31
  30. package/query/SqlIdentifier.ts +105 -0
  31. package/query/builders/FullTextSearchBuilder.ts +19 -6
  32. package/service/ServiceRegistry.ts +28 -9
  33. package/service/index.ts +4 -2
  34. package/storage/LocalStorageProvider.ts +12 -3
  35. package/storage/S3StorageProvider.ts +6 -6
  36. package/tests/e2e/http.test.ts +6 -2
  37. package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
  38. package/tests/unit/cache/CacheManager.test.ts +20 -0
  39. package/tests/unit/entity/Entity.components.test.ts +73 -0
  40. package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
  41. package/tests/unit/entity/Entity.reload.test.ts +63 -0
  42. package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
  43. package/tests/unit/query/Query.emptyString.test.ts +69 -0
  44. package/tests/unit/query/Query.test.ts +6 -4
  45. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
  46. package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
  47. package/upload/FileValidator.ts +9 -6
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Integration tests for Entity.save timeout and cancellation behavior.
3
+ *
4
+ * Regression coverage for the production incident where Entity.save's wall-
5
+ * clock timeout rejected the outer Promise but left the underlying Bun SQL
6
+ * transaction mid-flight. Under pgbouncer transaction-mode pooling this
7
+ * leaked backend PostgreSQL sessions into `idle in transaction` state,
8
+ * exhausting the pool.
9
+ *
10
+ * These tests prove the invariants the fix must uphold:
11
+ * 1. An aborted save leaves no partial rows — Bun SQL's transaction callback
12
+ * throws, auto-ROLLBACK fires, backend connection is released.
13
+ * 2. The connection pool stays healthy after repeated aborts — subsequent
14
+ * saves on fresh entities still succeed.
15
+ * 3. A save with no abort still commits normally.
16
+ *
17
+ * The wall-clock DB_QUERY_TIMEOUT path is module-cached at import time so it
18
+ * is not exercised here directly. Manual verification on a real Postgres +
19
+ * pgbouncer stack (with query_wait_timeout short enough to fire) should
20
+ * confirm pg_stat_activity shows no `idle in transaction` backends after
21
+ * this test suite runs. See the handoff doc (2026-04-18) for the repro steps.
22
+ */
23
+ import { describe, test, expect, beforeAll } from 'bun:test';
24
+ import { Entity } from '../../../core/Entity';
25
+ import db from '../../../database';
26
+ import { TestUser } from '../../fixtures/components';
27
+ import { createTestContext, ensureComponentsRegistered } from '../../utils';
28
+
29
+ describe('Entity.save timeout and cancellation', () => {
30
+ const ctx = createTestContext();
31
+
32
+ beforeAll(async () => {
33
+ await ensureComponentsRegistered(TestUser);
34
+ });
35
+
36
+ test('aborted doSave does not leave partial rows (transaction rolls back)', async () => {
37
+ const entity = ctx.tracker.create();
38
+ entity.add(TestUser, { name: 'aborted', email: 'a@example.com', age: 1 });
39
+
40
+ const controller = new AbortController();
41
+ // Abort immediately — the first in-flight query will be cancelled,
42
+ // the transaction callback throws, Bun SQL issues ROLLBACK.
43
+ queueMicrotask(() => controller.abort(new Error('simulated save timeout')));
44
+
45
+ const result = db.transaction(async (trx) => {
46
+ await entity.doSave(trx, controller.signal);
47
+ });
48
+
49
+ await expect(result).rejects.toBeDefined();
50
+
51
+ // Entity must NOT exist — rollback invariant.
52
+ const rows = await db`SELECT id FROM entities WHERE id = ${entity.id}`;
53
+ expect(rows.length).toBe(0);
54
+ });
55
+
56
+ test('connection pool stays healthy after multiple aborted saves', async () => {
57
+ // Repeatedly abort saves — if connections leaked, subsequent saves
58
+ // would eventually block on pool acquire.
59
+ for (let i = 0; i < 8; i++) {
60
+ const entity = Entity.Create();
61
+ entity.add(TestUser, { name: `aborted-${i}`, email: `a${i}@e.com`, age: i });
62
+
63
+ const controller = new AbortController();
64
+ queueMicrotask(() => controller.abort(new Error('simulated timeout')));
65
+
66
+ await db.transaction(async (trx) => {
67
+ await entity.doSave(trx, controller.signal);
68
+ }).catch(() => { /* expected */ });
69
+ }
70
+
71
+ // A fresh save must still succeed on the pool that serviced the aborts.
72
+ const healthy = ctx.tracker.create();
73
+ healthy.add(TestUser, { name: 'healthy', email: 'h@e.com', age: 99 });
74
+ await healthy.save();
75
+
76
+ expect(healthy._persisted).toBe(true);
77
+
78
+ const rows = await db`SELECT id FROM entities WHERE id = ${healthy.id}`;
79
+ expect(rows.length).toBe(1);
80
+ });
81
+
82
+ test('doSave without signal behaves normally (backwards compatible)', async () => {
83
+ const entity = ctx.tracker.create();
84
+ entity.add(TestUser, { name: 'no-signal', email: 'n@e.com', age: 5 });
85
+
86
+ await db.transaction(async (trx) => {
87
+ await entity.doSave(trx); // no signal passed
88
+ });
89
+
90
+ const rows = await db`SELECT id FROM entities WHERE id = ${entity.id}`;
91
+ expect(rows.length).toBe(1);
92
+ });
93
+
94
+ test('save() resolves even if post-commit cache work is slow (fire-and-forget)', async () => {
95
+ // Cache handler is queued via queueMicrotask; save() must resolve as
96
+ // soon as the DB transaction commits. We assert save resolves quickly
97
+ // even though handleCacheAfterSave is awaited separately.
98
+ const entity = ctx.tracker.create();
99
+ entity.add(TestUser, { name: 'fast', email: 'f@e.com', age: 10 });
100
+
101
+ const start = performance.now();
102
+ await entity.save();
103
+ const elapsed = performance.now() - start;
104
+
105
+ expect(entity._persisted).toBe(true);
106
+ // Generous bound — if cache were blocking save, timings under load
107
+ // could stretch past the budget. This just guards gross regressions.
108
+ expect(elapsed).toBeLessThan(5000);
109
+ });
110
+ });
@@ -141,6 +141,26 @@ describe('CacheManager', () => {
141
141
  expect(result).toBeNull();
142
142
  });
143
143
 
144
+ test('invalidateEntities clears entity + all component caches for a batch', async () => {
145
+ const provider = cacheManager.getProvider();
146
+ // Two entities, each with cached entity entry + one component
147
+ await provider.set('entity:e1', 'e1', 3600000);
148
+ await provider.set('entity:e2', 'e2', 3600000);
149
+ await provider.set('component:e1:t1', { data: 'a' }, 3600000);
150
+ await provider.set('component:e2:t1', { data: 'b' }, 3600000);
151
+
152
+ await cacheManager.invalidateEntities(['e1', 'e2']);
153
+
154
+ expect(await cacheManager.getEntity('e1')).toBeNull();
155
+ expect(await cacheManager.getEntity('e2')).toBeNull();
156
+ expect(await cacheManager.getComponentsByEntity('e1', 't1')).toBeNull();
157
+ expect(await cacheManager.getComponentsByEntity('e2', 't1')).toBeNull();
158
+ });
159
+
160
+ test('invalidateEntities is a noop for empty list', async () => {
161
+ await expect(cacheManager.invalidateEntities([])).resolves.toBeUndefined();
162
+ });
163
+
144
164
  test('getEntities returns null for missing entities', async () => {
145
165
  const results = await cacheManager.getEntities(['id1', 'id2', 'id3']);
146
166
  expect(results.length).toBe(3);
@@ -187,6 +187,79 @@ describe('Entity Component Management', () => {
187
187
 
188
188
  expect(data?.createdAt).toBe('2024-01-15T10:30:00.000Z');
189
189
  });
190
+
191
+ test('serializableData throws descriptive error on invalid Date', () => {
192
+ const entity = new Entity();
193
+ entity.add(TestOrder, {
194
+ orderNumber: 'ORD-002',
195
+ total: 50,
196
+ status: 'pending',
197
+ createdAt: new Date('not-a-date')
198
+ });
199
+
200
+ const component = entity.getInMemory(TestOrder);
201
+ expect(() => component?.serializableData()).toThrow(
202
+ /Invalid Date for property 'createdAt' on component 'TestOrder'/
203
+ );
204
+ });
205
+
206
+ test('serializableData throws on Date type mismatch', () => {
207
+ const entity = new Entity();
208
+ entity.add(TestOrder, {
209
+ orderNumber: 'ORD-003',
210
+ total: 50,
211
+ status: 'pending',
212
+ createdAt: '2024-01-15' as any
213
+ });
214
+
215
+ const component = entity.getInMemory(TestOrder);
216
+ expect(() => component?.serializableData()).toThrow(
217
+ /Type mismatch for property 'createdAt' on component 'TestOrder': expected Date, got string/
218
+ );
219
+ });
220
+
221
+ test('serializableData throws on NaN number', () => {
222
+ const entity = new Entity();
223
+ entity.add(TestOrder, {
224
+ orderNumber: 'ORD-004',
225
+ total: NaN,
226
+ status: 'pending',
227
+ createdAt: new Date()
228
+ });
229
+
230
+ const component = entity.getInMemory(TestOrder);
231
+ expect(() => component?.serializableData()).toThrow(
232
+ /Invalid number for property 'total' on component 'TestOrder'/
233
+ );
234
+ });
235
+
236
+ test('serializableData throws on Infinity number', () => {
237
+ const entity = new Entity();
238
+ entity.add(TestOrder, {
239
+ orderNumber: 'ORD-005',
240
+ total: Infinity,
241
+ status: 'pending',
242
+ createdAt: new Date()
243
+ });
244
+
245
+ const component = entity.getInMemory(TestOrder);
246
+ expect(() => component?.serializableData()).toThrow(
247
+ /Invalid number for property 'total' on component 'TestOrder'/
248
+ );
249
+ });
250
+
251
+ test('serializableData allows null/undefined for nullable Date/Number', () => {
252
+ const entity = new Entity();
253
+ entity.add(TestOrder, {
254
+ orderNumber: 'ORD-006',
255
+ total: 0,
256
+ status: 'pending',
257
+ createdAt: null as any
258
+ });
259
+
260
+ const component = entity.getInMemory(TestOrder);
261
+ expect(() => component?.serializableData()).not.toThrow();
262
+ });
190
263
  });
191
264
 
192
265
  describe('component state', () => {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * BUNSANE-001 defensive harness: verify Entity.drainPendingSideEffects()
3
+ * awaits post-commit work scheduled via queueMicrotask from save(), so
4
+ * tests under PGlite can settle prior-file background work before
5
+ * asserting against freshly-committed state.
6
+ */
7
+ import { describe, test, expect, beforeAll } from 'bun:test';
8
+ import { Entity } from '../../../core/Entity';
9
+ import { BaseComponent } from '../../../core/components/BaseComponent';
10
+ import { Component, CompData } from '../../../core/components/Decorators';
11
+ import { ensureComponentsRegistered } from '../../utils';
12
+
13
+ @Component
14
+ class DrainMarker extends BaseComponent {
15
+ @CompData()
16
+ value: string = '';
17
+ }
18
+
19
+ describe('Entity.drainPendingSideEffects', () => {
20
+ beforeAll(async () => {
21
+ await ensureComponentsRegistered(DrainMarker);
22
+ });
23
+
24
+ test('no-op when nothing is pending', async () => {
25
+ await expect(Entity.drainPendingSideEffects(100)).resolves.toBeUndefined();
26
+ });
27
+
28
+ test('awaits post-commit side effects scheduled by save()', async () => {
29
+ const saved = Entity.Create();
30
+ saved.add(DrainMarker, { value: 'pending' });
31
+ await saved.save();
32
+
33
+ // runPostCommitSideEffects is queued as a microtask. drain() must
34
+ // settle it before returning.
35
+ await Entity.drainPendingSideEffects(2_000);
36
+
37
+ // A second drain is a no-op.
38
+ await Entity.drainPendingSideEffects(100);
39
+ });
40
+
41
+ test('bounded by timeout, returns even if drain exceeds it', async () => {
42
+ const saved = Entity.Create();
43
+ saved.add(DrainMarker, { value: 'bounded' });
44
+ await saved.save();
45
+
46
+ const start = Date.now();
47
+ await Entity.drainPendingSideEffects(1);
48
+ const elapsed = Date.now() - start;
49
+ expect(elapsed).toBeLessThan(500);
50
+ });
51
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Unit tests for Entity.reload (BUNSANE-006).
3
+ * Ensures in-memory component state is discarded and re-hydrated from DB.
4
+ */
5
+ import { describe, test, expect, beforeAll } from 'bun:test';
6
+ import { Entity } from '../../../core/Entity';
7
+ import { BaseComponent } from '../../../core/components/BaseComponent';
8
+ import { Component, CompData } from '../../../core/components/Decorators';
9
+ import { ensureComponentsRegistered } from '../../utils';
10
+ import db from '../../../database';
11
+
12
+ @Component
13
+ class ReloadStatus extends BaseComponent {
14
+ @CompData()
15
+ value: string = '';
16
+ }
17
+
18
+ describe('Entity.reload', () => {
19
+ beforeAll(async () => {
20
+ await ensureComponentsRegistered(ReloadStatus);
21
+ });
22
+
23
+ test('no-op on entity without a valid id', async () => {
24
+ const entity = new Entity('');
25
+ await expect(entity.reload()).resolves.toBe(entity);
26
+ });
27
+
28
+ test('refreshes in-memory data after raw-SQL write', async () => {
29
+ const saved = Entity.Create();
30
+ saved.add(ReloadStatus, { value: 'before' });
31
+ await saved.save();
32
+
33
+ const typeId = new ReloadStatus().getTypeID();
34
+
35
+ // Write new value via raw SQL — bypasses entity cache invalidation.
36
+ await db.unsafe(
37
+ `UPDATE components SET data = data || '{"value":"after"}'::jsonb
38
+ WHERE entity_id = $1 AND type_id = $2`,
39
+ [saved.id, typeId]
40
+ );
41
+
42
+ // In-memory copy still holds stale value.
43
+ expect(saved.getInMemory(ReloadStatus)?.value).toBe('before');
44
+
45
+ const returned = await saved.reload();
46
+ expect(returned).toBe(saved);
47
+ expect(saved.getInMemory(ReloadStatus)?.value).toBe('after');
48
+ });
49
+
50
+ test('hydrates a bare Entity instance from DB', async () => {
51
+ const saved = Entity.Create();
52
+ saved.add(ReloadStatus, { value: 'hydrated' });
53
+ await saved.save();
54
+
55
+ const bare = new Entity(saved.id);
56
+ expect(bare.componentList().length).toBe(0);
57
+
58
+ await bare.reload();
59
+
60
+ expect(bare.hasInMemory(ReloadStatus)).toBe(true);
61
+ expect(bare.getInMemory(ReloadStatus)?.value).toBe('hydrated');
62
+ });
63
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Unit tests for Entity.requireComponents (BUNSANE-003).
3
+ * Ensures ComponentTargetHook includeComponents matching sees tag
4
+ * components that weren't eagerly loaded.
5
+ */
6
+ import { describe, test, expect, beforeAll } from 'bun:test';
7
+ import { Entity } from '../../../core/Entity';
8
+ import { BaseComponent } from '../../../core/components/BaseComponent';
9
+ import { Component, CompData } from '../../../core/components/Decorators';
10
+ import { TestUser } from '../../fixtures/components';
11
+ import { ensureComponentsRegistered } from '../../utils';
12
+
13
+ @Component
14
+ class ReqTag extends BaseComponent {}
15
+
16
+ @Component
17
+ class ReqData extends BaseComponent {
18
+ @CompData()
19
+ value: string = '';
20
+ }
21
+
22
+ describe('Entity.requireComponents', () => {
23
+ beforeAll(async () => {
24
+ await ensureComponentsRegistered(TestUser, ReqTag, ReqData);
25
+ });
26
+
27
+ test('no-op for empty list', async () => {
28
+ const entity = Entity.Create();
29
+ await expect(entity.requireComponents([])).resolves.toBeUndefined();
30
+ expect(entity.componentList().length).toBe(0);
31
+ });
32
+
33
+ test('does nothing when components already in memory', async () => {
34
+ const entity = Entity.Create();
35
+ entity.add(ReqTag);
36
+ const before = entity.componentList().length;
37
+ await entity.requireComponents([ReqTag]);
38
+ expect(entity.componentList().length).toBe(before);
39
+ });
40
+
41
+ test('hydrates missing components from DB after save', async () => {
42
+ const saved = Entity.Create();
43
+ saved.add(ReqTag);
44
+ saved.add(ReqData, { value: 'hello' });
45
+ await saved.save();
46
+
47
+ const loaded = new Entity(saved.id);
48
+ expect(loaded.componentList().length).toBe(0);
49
+
50
+ await loaded.requireComponents([ReqTag, ReqData]);
51
+
52
+ expect(loaded.hasInMemory(ReqTag)).toBe(true);
53
+ expect(loaded.hasInMemory(ReqData)).toBe(true);
54
+ expect(loaded.getInMemory(ReqData)?.value).toBe('hello');
55
+ });
56
+
57
+ test('only fetches missing components, not already-loaded ones', async () => {
58
+ const saved = Entity.Create();
59
+ saved.add(ReqTag);
60
+ saved.add(ReqData, { value: 'mix' });
61
+ await saved.save();
62
+
63
+ const loaded = new Entity(saved.id);
64
+ await loaded.requireComponents([ReqData]);
65
+ expect(loaded.hasInMemory(ReqData)).toBe(true);
66
+ expect(loaded.hasInMemory(ReqTag)).toBe(false);
67
+
68
+ await loaded.requireComponents([ReqTag, ReqData]);
69
+ expect(loaded.hasInMemory(ReqTag)).toBe(true);
70
+ expect(loaded.getInMemory(ReqData)?.value).toBe('mix');
71
+ });
72
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Empty-string filter support. JSONB text extraction (c.data->>'field')
3
+ * returns text, so `= ''` / `!= ''` / LIKE against empty string are
4
+ * legitimate. UUID-cast path is gated on a regex that empty cannot match.
5
+ */
6
+ import { describe, test, expect, beforeAll } from 'bun:test';
7
+ import { Entity } from '../../../core/Entity';
8
+ import { BaseComponent } from '../../../core/components/BaseComponent';
9
+ import { Component, CompData } from '../../../core/components/Decorators';
10
+ import { Query, FilterOp } from '../../../query/Query';
11
+ import { ensureComponentsRegistered } from '../../utils';
12
+
13
+ @Component
14
+ class EmptyableNote extends BaseComponent {
15
+ @CompData()
16
+ value: string = '';
17
+ }
18
+
19
+ describe('Query empty-string filter', () => {
20
+ beforeAll(async () => {
21
+ await ensureComponentsRegistered(EmptyableNote);
22
+ });
23
+
24
+ test('Query.filter accepts empty-string value without throwing', () => {
25
+ expect(() => Query.filter('value', FilterOp.EQ, '')).not.toThrow();
26
+ const f = Query.filter('value', FilterOp.EQ, '');
27
+ expect(f.value).toBe('');
28
+ });
29
+
30
+ test('Query.filter accepts whitespace-only value without throwing', () => {
31
+ expect(() => Query.filter('value', FilterOp.EQ, ' ')).not.toThrow();
32
+ });
33
+
34
+ test('.with(C, filter EQ "") executes and returns matching rows', async () => {
35
+ const withEmpty = Entity.Create();
36
+ withEmpty.add(EmptyableNote, { value: '' });
37
+ await withEmpty.save();
38
+
39
+ const withData = Entity.Create();
40
+ withData.add(EmptyableNote, { value: 'not empty' });
41
+ await withData.save();
42
+
43
+ const rows = await new Query()
44
+ .with(EmptyableNote, Query.filters(Query.filter('value', FilterOp.EQ, '')))
45
+ .exec();
46
+
47
+ const ids = rows.map(e => e.id);
48
+ expect(ids).toContain(withEmpty.id);
49
+ expect(ids).not.toContain(withData.id);
50
+ });
51
+
52
+ test('.with(C, filter != "") excludes rows with empty value', async () => {
53
+ const withEmpty = Entity.Create();
54
+ withEmpty.add(EmptyableNote, { value: '' });
55
+ await withEmpty.save();
56
+
57
+ const withData = Entity.Create();
58
+ withData.add(EmptyableNote, { value: 'populated' });
59
+ await withData.save();
60
+
61
+ const rows = await new Query()
62
+ .with(EmptyableNote, Query.filters(Query.filter('value', FilterOp.NEQ, '')))
63
+ .exec();
64
+
65
+ const ids = rows.map(e => e.id);
66
+ expect(ids).toContain(withData.id);
67
+ expect(ids).not.toContain(withEmpty.id);
68
+ });
69
+ });
@@ -192,12 +192,14 @@ describe('Query', () => {
192
192
  expect(filter.value).toBe('John');
193
193
  });
194
194
 
195
- test('throws for empty string value', () => {
196
- expect(() => Query.filter('name', FilterOp.EQ, '')).toThrow();
195
+ test('accepts empty string value', () => {
196
+ const filter = Query.filter('name', FilterOp.EQ, '');
197
+ expect(filter.value).toBe('');
197
198
  });
198
199
 
199
- test('throws for whitespace value', () => {
200
- expect(() => Query.filter('name', FilterOp.EQ, ' ')).toThrow();
200
+ test('accepts whitespace value', () => {
201
+ const filter = Query.filter('name', FilterOp.EQ, ' ');
202
+ expect(filter.value).toBe(' ');
201
203
  });
202
204
  });
203
205
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Unit tests for SchedulerManager time-based (entity-less) tasks.
3
+ * Covers BUNSANE-002: @ScheduledTask without query/componentTarget.
4
+ */
5
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
6
+ import { SchedulerManager } from '../../../core/SchedulerManager';
7
+ import { ScheduleInterval } from '../../../types/scheduler.types';
8
+
9
+ describe('SchedulerManager time-based tasks', () => {
10
+ let scheduler: SchedulerManager;
11
+
12
+ beforeEach(() => {
13
+ scheduler = SchedulerManager.getInstance();
14
+ scheduler.updateConfig({
15
+ enabled: true,
16
+ enableLogging: false,
17
+ runOnStart: false,
18
+ distributedLocking: false,
19
+ maxConcurrentTasks: 5,
20
+ defaultTimeout: 5000,
21
+ });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await scheduler.stop().catch(() => {});
26
+ });
27
+
28
+ test('registers task with no query / no componentTarget', () => {
29
+ let called = 0;
30
+ const service = {
31
+ tick: async () => {
32
+ called++;
33
+ },
34
+ };
35
+
36
+ expect(() =>
37
+ scheduler.registerTask({
38
+ id: 'test.timebased.register',
39
+ name: 'timebased-register',
40
+ interval: ScheduleInterval.MINUTE,
41
+ options: {},
42
+ service,
43
+ methodName: 'tick',
44
+ nextExecution: new Date(),
45
+ executionCount: 0,
46
+ isRunning: false,
47
+ enabled: true,
48
+ })
49
+ ).not.toThrow();
50
+ });
51
+
52
+ test('executes handler with no entity argument', async () => {
53
+ const receivedArgsBox: { args: unknown[] | null } = { args: null };
54
+ const service = {
55
+ tick: async (...args: unknown[]) => {
56
+ receivedArgsBox.args = args;
57
+ },
58
+ };
59
+
60
+ scheduler.registerTask({
61
+ id: 'test.timebased.exec',
62
+ name: 'timebased-exec',
63
+ interval: ScheduleInterval.MINUTE,
64
+ options: {},
65
+ service,
66
+ methodName: 'tick',
67
+ nextExecution: new Date(),
68
+ executionCount: 0,
69
+ isRunning: false,
70
+ enabled: true,
71
+ });
72
+
73
+ const ok = await scheduler.executeTaskNow('test.timebased.exec');
74
+ expect(ok).toBe(true);
75
+ expect(receivedArgsBox.args).toEqual([]);
76
+ });
77
+
78
+ test('rejects task still missing required fields', () => {
79
+ const service = { tick: async () => {} };
80
+ expect(() =>
81
+ scheduler.registerTask({
82
+ // missing id
83
+ name: 'bad',
84
+ interval: ScheduleInterval.MINUTE,
85
+ options: {},
86
+ service,
87
+ methodName: 'tick',
88
+ nextExecution: new Date(),
89
+ executionCount: 0,
90
+ isRunning: false,
91
+ enabled: true,
92
+ } as any)
93
+ ).toThrow(/missing required fields/);
94
+ });
95
+ });
@@ -378,11 +378,9 @@ describe("S3StorageProvider", () => {
378
378
 
379
379
  it("returns false on failure", async () => {
380
380
  const client = createMockS3Client({
381
- file: mock(() => ({
382
- arrayBuffer: async () => {
383
- throw new Error("Read failed");
384
- },
385
- })),
381
+ stat: mock(async () => {
382
+ throw new Error("Stat failed");
383
+ }),
386
384
  });
387
385
  const provider = new S3StorageProvider(
388
386
  { bucket: "my-bucket" },
@@ -413,11 +411,9 @@ describe("S3StorageProvider", () => {
413
411
 
414
412
  it("returns false if copy fails", async () => {
415
413
  const client = createMockS3Client({
416
- file: mock(() => ({
417
- arrayBuffer: async () => {
418
- throw new Error("Read failed");
419
- },
420
- })),
414
+ stat: mock(async () => {
415
+ throw new Error("Stat failed");
416
+ }),
421
417
  });
422
418
  const provider = new S3StorageProvider(
423
419
  { bucket: "my-bucket" },
@@ -258,31 +258,34 @@ export class FileValidator {
258
258
  * Check if file is potentially dangerous
259
259
  */
260
260
  public async isDangerous(file: File): Promise<boolean> {
261
- // Check for executable file extensions
262
261
  const dangerousExtensions = [
263
262
  '.exe', '.scr', '.bat', '.cmd', '.com', '.pif', '.vbs', '.js', '.jar',
264
- '.sh', '.py', '.pl', '.php', '.asp', '.aspx', '.jsp'
263
+ '.sh', '.py', '.pl', '.php', '.asp', '.aspx', '.jsp',
264
+ '.svg',
265
265
  ];
266
+ const dangerousMimeTypes = ['image/svg+xml'];
266
267
 
267
268
  const extension = this.getFileExtension(file.name);
268
269
  if (dangerousExtensions.includes(extension)) {
269
270
  return true;
270
271
  }
272
+ if (dangerousMimeTypes.includes(file.type)) {
273
+ return true;
274
+ }
271
275
 
272
- // Check for polyglot files (files that are valid in multiple formats)
273
276
  try {
274
277
  const buffer = await file.slice(0, 1024).arrayBuffer();
275
278
  const bytes = new Uint8Array(buffer);
276
279
  const content = new TextDecoder().decode(bytes);
277
-
278
- // Look for script patterns
280
+
279
281
  const scriptPatterns = [
280
282
  /<script/i,
281
283
  /javascript:/i,
282
284
  /vbscript:/i,
283
285
  /<iframe/i,
284
286
  /<object/i,
285
- /<embed/i
287
+ /<embed/i,
288
+ /on[a-z]+\s*=/i,
286
289
  ];
287
290
 
288
291
  return scriptPatterns.some(pattern => pattern.test(content));