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.
- package/CHANGELOG.md +318 -0
- package/CLAUDE.md +20 -0
- package/config/cache.config.ts +12 -2
- package/core/App.ts +300 -69
- package/core/ApplicationLifecycle.ts +68 -4
- package/core/Entity.ts +525 -256
- package/core/EntityHookManager.ts +88 -21
- package/core/EntityManager.ts +12 -3
- package/core/Logger.ts +4 -0
- package/core/RequestContext.ts +4 -1
- package/core/SchedulerManager.ts +105 -22
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheManager.ts +72 -17
- package/core/cache/RedisCache.ts +38 -3
- package/core/components/BaseComponent.ts +12 -2
- package/core/decorators/EntityHooks.ts +24 -12
- package/core/middleware/RateLimit.ts +105 -0
- package/core/middleware/index.ts +1 -0
- package/core/remote/OutboxWorker.ts +42 -35
- package/core/scheduler/DistributedLock.ts +22 -7
- package/database/PreparedStatementCache.ts +5 -13
- package/gql/builders/ResolverBuilder.ts +4 -4
- package/gql/complexityLimit.ts +95 -0
- package/gql/index.ts +15 -3
- package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +18 -11
- package/query/OrNode.ts +2 -4
- package/query/Query.ts +42 -31
- package/query/SqlIdentifier.ts +105 -0
- package/query/builders/FullTextSearchBuilder.ts +19 -6
- package/service/ServiceRegistry.ts +28 -9
- package/service/index.ts +4 -2
- package/storage/LocalStorageProvider.ts +12 -3
- package/storage/S3StorageProvider.ts +6 -6
- package/tests/e2e/http.test.ts +6 -2
- package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
- package/tests/unit/cache/CacheManager.test.ts +20 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
- 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('
|
|
196
|
-
|
|
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('
|
|
200
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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" },
|
package/upload/FileValidator.ts
CHANGED
|
@@ -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));
|