@critical-path/core 0.6.0 → 0.8.0
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 +35 -0
- package/README.md +6 -0
- package/dist/domain/entities.d.ts +4 -0
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +14 -0
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/events.d.ts +32 -4
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/index.d.ts +23 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +132 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +26 -0
- package/dist/index.test.js.map +1 -1
- package/dist/storage/file-storage.d.ts +24 -0
- package/dist/storage/file-storage.d.ts.map +1 -0
- package/dist/storage/file-storage.js +73 -0
- package/dist/storage/file-storage.js.map +1 -0
- package/dist/storage/firebase-storage.d.ts +48 -0
- package/dist/storage/firebase-storage.d.ts.map +1 -0
- package/dist/storage/firebase-storage.js +142 -0
- package/dist/storage/firebase-storage.js.map +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +4 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/s3.d.ts +36 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +135 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage/storage.test.d.ts +2 -0
- package/dist/storage/storage.test.d.ts.map +1 -0
- package/dist/storage/storage.test.js +117 -0
- package/dist/storage/storage.test.js.map +1 -0
- package/dist/store/firebase.d.ts +13 -1
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +95 -18
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +12 -0
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/index.d.ts +27 -2
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +49 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +12 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +128 -6
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sqlite.test.js +47 -1
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +52 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/entities.ts +19 -0
- package/src/domain/events.ts +28 -3
- package/src/engine/index.ts +168 -3
- package/src/index.test.ts +32 -0
- package/src/index.ts +2 -0
- package/src/storage/file-storage.ts +96 -0
- package/src/storage/firebase-storage.ts +177 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/s3.ts +165 -0
- package/src/storage/storage.test.ts +146 -0
- package/src/store/firebase.test.ts +15 -0
- package/src/store/firebase.ts +97 -18
- package/src/store/index.ts +64 -0
- package/src/store/sqlite.test.ts +59 -1
- package/src/store/sqlite.ts +172 -6
- package/src/types/index.ts +63 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileStorageAdapter,
|
|
3
|
+
UploadFileInput,
|
|
4
|
+
UploadFileResult,
|
|
5
|
+
PresignedUrlOptions,
|
|
6
|
+
PresignedUploadResult
|
|
7
|
+
} from '../types/index.js';
|
|
8
|
+
|
|
9
|
+
export interface FirebaseStorageBucketInterface {
|
|
10
|
+
name?: string;
|
|
11
|
+
file(path: string): {
|
|
12
|
+
save(
|
|
13
|
+
data: Uint8Array | Buffer | string,
|
|
14
|
+
options?: { metadata?: { contentType?: string; [key: string]: any }; resumable?: boolean }
|
|
15
|
+
): Promise<void>;
|
|
16
|
+
delete(options?: any): Promise<any>;
|
|
17
|
+
getSignedUrl?(config: { action: 'read' | 'write'; expires: string | number | Date; contentType?: string }): Promise<[string]>;
|
|
18
|
+
publicUrl?(): string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class InMemoryFirebaseStorageMock implements FirebaseStorageBucketInterface {
|
|
23
|
+
name: string;
|
|
24
|
+
private files = new Map<string, { data: Uint8Array; contentType: string }>();
|
|
25
|
+
|
|
26
|
+
constructor(name: string = 'mock-firebase-bucket') {
|
|
27
|
+
this.name = name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
file(path: string) {
|
|
31
|
+
const files = this.files;
|
|
32
|
+
const bucketName = this.name;
|
|
33
|
+
return {
|
|
34
|
+
async save(data: Uint8Array | Buffer | string, options?: any) {
|
|
35
|
+
let bytes: Uint8Array;
|
|
36
|
+
if (typeof data === 'string') {
|
|
37
|
+
bytes = new TextEncoder().encode(data);
|
|
38
|
+
} else if (data instanceof Uint8Array) {
|
|
39
|
+
bytes = data;
|
|
40
|
+
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) {
|
|
41
|
+
bytes = new Uint8Array(data);
|
|
42
|
+
} else {
|
|
43
|
+
bytes = new Uint8Array();
|
|
44
|
+
}
|
|
45
|
+
files.set(path, {
|
|
46
|
+
data: bytes,
|
|
47
|
+
contentType: options?.metadata?.contentType || 'application/octet-stream'
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
async delete() {
|
|
51
|
+
files.delete(path);
|
|
52
|
+
},
|
|
53
|
+
async getSignedUrl(config: any): Promise<[string]> {
|
|
54
|
+
return [`https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucketName)}/o/${encodeURIComponent(path)}?alt=media&token=mock-signed-token`];
|
|
55
|
+
},
|
|
56
|
+
publicUrl() {
|
|
57
|
+
return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucketName)}/o/${encodeURIComponent(path)}?alt=media`;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FirebaseStorageConfig {
|
|
64
|
+
bucket?: FirebaseStorageBucketInterface;
|
|
65
|
+
bucketName?: string;
|
|
66
|
+
publicUrlBase?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class FirebaseStorageAdapter implements FileStorageAdapter {
|
|
70
|
+
private bucket: FirebaseStorageBucketInterface;
|
|
71
|
+
private bucketName: string;
|
|
72
|
+
private publicUrlBase?: string;
|
|
73
|
+
|
|
74
|
+
constructor(config: FirebaseStorageConfig = {}) {
|
|
75
|
+
this.bucket = config.bucket || new InMemoryFirebaseStorageMock(config.bucketName || 'default-bucket');
|
|
76
|
+
this.bucketName = config.bucketName || this.bucket.name || 'default-bucket';
|
|
77
|
+
if (this.bucket && config.bucketName && this.bucket.name !== config.bucketName) {
|
|
78
|
+
this.bucket.name = config.bucketName;
|
|
79
|
+
}
|
|
80
|
+
this.publicUrlBase = config.publicUrlBase?.replace(/\/+$/, '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private getPublicUrl(storageKey: string): string {
|
|
84
|
+
if (this.publicUrlBase) {
|
|
85
|
+
return `${this.publicUrlBase}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
86
|
+
}
|
|
87
|
+
const fileRef = this.bucket.file(storageKey);
|
|
88
|
+
if (typeof fileRef.publicUrl === 'function') {
|
|
89
|
+
return fileRef.publicUrl();
|
|
90
|
+
}
|
|
91
|
+
return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(this.bucketName)}/o/${encodeURIComponent(storageKey)}?alt=media`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
95
|
+
const prefix = input.pathPrefix ? `${input.pathPrefix.replace(/\/+$/, '')}/` : '';
|
|
96
|
+
const randomId = Math.random().toString(36).substring(2, 9);
|
|
97
|
+
const sanitizedFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
98
|
+
const storageKey = `${prefix}${Date.now()}_${randomId}_${sanitizedFilename}`;
|
|
99
|
+
|
|
100
|
+
let bodyData: Uint8Array;
|
|
101
|
+
if (typeof input.data === 'string') {
|
|
102
|
+
bodyData = new TextEncoder().encode(input.data);
|
|
103
|
+
} else if (input.data instanceof Uint8Array) {
|
|
104
|
+
bodyData = input.data;
|
|
105
|
+
} else if (input.data instanceof ArrayBuffer) {
|
|
106
|
+
bodyData = new Uint8Array(input.data);
|
|
107
|
+
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input.data)) {
|
|
108
|
+
bodyData = new Uint8Array(input.data);
|
|
109
|
+
} else {
|
|
110
|
+
bodyData = new Uint8Array();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const mimeType = input.mimeType || 'application/octet-stream';
|
|
114
|
+
const sizeBytes = bodyData.byteLength;
|
|
115
|
+
|
|
116
|
+
const fileRef = this.bucket.file(storageKey);
|
|
117
|
+
await fileRef.save(bodyData, {
|
|
118
|
+
metadata: {
|
|
119
|
+
contentType: mimeType
|
|
120
|
+
},
|
|
121
|
+
resumable: false
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const url = this.getPublicUrl(storageKey);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
storageKey,
|
|
128
|
+
url,
|
|
129
|
+
sizeBytes,
|
|
130
|
+
mimeType
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async delete(storageKey: string): Promise<boolean> {
|
|
135
|
+
try {
|
|
136
|
+
const fileRef = this.bucket.file(storageKey);
|
|
137
|
+
await fileRef.delete();
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getDownloadUrl(storageKey: string): Promise<string> {
|
|
145
|
+
return this.getPublicUrl(storageKey);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async getPresignedUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
149
|
+
const storageKey = options.storageKey;
|
|
150
|
+
const fileRef = this.bucket.file(storageKey);
|
|
151
|
+
|
|
152
|
+
if (typeof fileRef.getSignedUrl === 'function') {
|
|
153
|
+
const expiresInMs = (options.expiresInSeconds || 900) * 1000;
|
|
154
|
+
const expires = Date.now() + expiresInMs;
|
|
155
|
+
const [url] = await fileRef.getSignedUrl({
|
|
156
|
+
action: 'write',
|
|
157
|
+
expires,
|
|
158
|
+
contentType: options.contentType
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
uploadUrl: url,
|
|
163
|
+
storageKey,
|
|
164
|
+
method: 'PUT',
|
|
165
|
+
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fallback to direct public upload url
|
|
170
|
+
return {
|
|
171
|
+
uploadUrl: this.getPublicUrl(storageKey),
|
|
172
|
+
storageKey,
|
|
173
|
+
method: 'PUT',
|
|
174
|
+
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileStorageAdapter,
|
|
3
|
+
UploadFileInput,
|
|
4
|
+
UploadFileResult,
|
|
5
|
+
PresignedUrlOptions,
|
|
6
|
+
PresignedUploadResult
|
|
7
|
+
} from '../types/index.js';
|
|
8
|
+
|
|
9
|
+
export interface S3ClientInterface {
|
|
10
|
+
send?(command: any): Promise<any>;
|
|
11
|
+
putObject?(params: any): Promise<any>;
|
|
12
|
+
deleteObject?(params: any): Promise<any>;
|
|
13
|
+
getSignedUrlPromise?(operation: string, params: any): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface S3StorageConfig {
|
|
17
|
+
bucket: string;
|
|
18
|
+
region?: string;
|
|
19
|
+
endpoint?: string;
|
|
20
|
+
accessKeyId?: string;
|
|
21
|
+
secretAccessKey?: string;
|
|
22
|
+
sessionToken?: string;
|
|
23
|
+
publicUrlBase?: string;
|
|
24
|
+
forcePathStyle?: boolean;
|
|
25
|
+
s3Client?: S3ClientInterface;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class S3StorageAdapter implements FileStorageAdapter {
|
|
29
|
+
private bucket: string;
|
|
30
|
+
private region: string;
|
|
31
|
+
private endpoint: string;
|
|
32
|
+
private accessKeyId?: string;
|
|
33
|
+
private secretAccessKey?: string;
|
|
34
|
+
private sessionToken?: string;
|
|
35
|
+
private publicUrlBase?: string;
|
|
36
|
+
private forcePathStyle: boolean;
|
|
37
|
+
private s3Client?: S3ClientInterface;
|
|
38
|
+
|
|
39
|
+
constructor(config: S3StorageConfig) {
|
|
40
|
+
if (!config.bucket) {
|
|
41
|
+
throw new Error('S3StorageAdapter requires a "bucket" name in its config.');
|
|
42
|
+
}
|
|
43
|
+
this.bucket = config.bucket;
|
|
44
|
+
this.region = config.region || 'us-east-1';
|
|
45
|
+
this.endpoint = (config.endpoint || `https://s3.${this.region}.amazonaws.com`).replace(/\/+$/, '');
|
|
46
|
+
this.accessKeyId = config.accessKeyId;
|
|
47
|
+
this.secretAccessKey = config.secretAccessKey;
|
|
48
|
+
this.sessionToken = config.sessionToken;
|
|
49
|
+
this.publicUrlBase = config.publicUrlBase?.replace(/\/+$/, '');
|
|
50
|
+
this.forcePathStyle = config.forcePathStyle ?? (this.endpoint.includes('localhost') || this.endpoint.includes('127.0.0.1'));
|
|
51
|
+
this.s3Client = config.s3Client;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private getObjectUrl(storageKey: string): string {
|
|
55
|
+
if (this.publicUrlBase) {
|
|
56
|
+
return `${this.publicUrlBase}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
57
|
+
}
|
|
58
|
+
if (this.forcePathStyle) {
|
|
59
|
+
return `${this.endpoint}/${this.bucket}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
60
|
+
}
|
|
61
|
+
const hostWithBucket = this.endpoint.replace('://', `://${this.bucket}.`);
|
|
62
|
+
return `${hostWithBucket}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
66
|
+
const prefix = input.pathPrefix ? `${input.pathPrefix.replace(/\/+$/, '')}/` : '';
|
|
67
|
+
const randomId = Math.random().toString(36).substring(2, 9);
|
|
68
|
+
const sanitizedFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
69
|
+
const storageKey = `${prefix}${Date.now()}_${randomId}_${sanitizedFilename}`;
|
|
70
|
+
|
|
71
|
+
let bodyData: Uint8Array;
|
|
72
|
+
if (typeof input.data === 'string') {
|
|
73
|
+
bodyData = new TextEncoder().encode(input.data);
|
|
74
|
+
} else if (input.data instanceof Uint8Array) {
|
|
75
|
+
bodyData = input.data;
|
|
76
|
+
} else if (input.data instanceof ArrayBuffer) {
|
|
77
|
+
bodyData = new Uint8Array(input.data);
|
|
78
|
+
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input.data)) {
|
|
79
|
+
bodyData = new Uint8Array(input.data);
|
|
80
|
+
} else {
|
|
81
|
+
bodyData = new Uint8Array();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mimeType = input.mimeType || 'application/octet-stream';
|
|
85
|
+
const sizeBytes = bodyData.byteLength;
|
|
86
|
+
const url = this.getObjectUrl(storageKey);
|
|
87
|
+
|
|
88
|
+
if (this.s3Client) {
|
|
89
|
+
try {
|
|
90
|
+
if (typeof this.s3Client.send === 'function') {
|
|
91
|
+
await this.s3Client.send({
|
|
92
|
+
input: {
|
|
93
|
+
Bucket: this.bucket,
|
|
94
|
+
Key: storageKey,
|
|
95
|
+
Body: bodyData,
|
|
96
|
+
ContentType: mimeType
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
} else if (typeof this.s3Client.putObject === 'function') {
|
|
100
|
+
await this.s3Client.putObject({
|
|
101
|
+
Bucket: this.bucket,
|
|
102
|
+
Key: storageKey,
|
|
103
|
+
Body: bodyData,
|
|
104
|
+
ContentType: mimeType
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Fallback
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
storageKey,
|
|
114
|
+
url,
|
|
115
|
+
sizeBytes,
|
|
116
|
+
mimeType
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async delete(storageKey: string): Promise<boolean> {
|
|
121
|
+
if (this.s3Client) {
|
|
122
|
+
try {
|
|
123
|
+
if (typeof this.s3Client.send === 'function') {
|
|
124
|
+
await this.s3Client.send({
|
|
125
|
+
input: {
|
|
126
|
+
Bucket: this.bucket,
|
|
127
|
+
Key: storageKey
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return true;
|
|
131
|
+
} else if (typeof this.s3Client.deleteObject === 'function') {
|
|
132
|
+
await this.s3Client.deleteObject({
|
|
133
|
+
Bucket: this.bucket,
|
|
134
|
+
Key: storageKey
|
|
135
|
+
});
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async getDownloadUrl(storageKey: string): Promise<string> {
|
|
146
|
+
return this.getObjectUrl(storageKey);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async getPresignedUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
150
|
+
const storageKey = options.storageKey;
|
|
151
|
+
const uploadUrl = this.getObjectUrl(storageKey);
|
|
152
|
+
|
|
153
|
+
const headers: Record<string, string> = {};
|
|
154
|
+
if (options.contentType) {
|
|
155
|
+
headers['Content-Type'] = options.contentType;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
uploadUrl,
|
|
160
|
+
storageKey,
|
|
161
|
+
method: 'PUT',
|
|
162
|
+
headers
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
InMemoryFileStore,
|
|
4
|
+
S3StorageAdapter,
|
|
5
|
+
FirebaseStorageAdapter,
|
|
6
|
+
InMemoryFirebaseStorageMock,
|
|
7
|
+
CriticalPathEngine
|
|
8
|
+
} from '../index.js';
|
|
9
|
+
|
|
10
|
+
describe('Storage Adapters', () => {
|
|
11
|
+
describe('InMemoryFileStore', () => {
|
|
12
|
+
it('uploads, downloads, and deletes files in memory', async () => {
|
|
13
|
+
const store = new InMemoryFileStore({ publicUrlBase: 'https://cdn.example.com/files' });
|
|
14
|
+
|
|
15
|
+
const uploadResult = await store.upload({
|
|
16
|
+
filename: 'diagram.png',
|
|
17
|
+
data: 'file-contents-here',
|
|
18
|
+
mimeType: 'image/png',
|
|
19
|
+
pathPrefix: 'attachments/task-1'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(uploadResult.storageKey).toContain('attachments/task-1/');
|
|
23
|
+
expect(uploadResult.storageKey).toContain('diagram.png');
|
|
24
|
+
expect(uploadResult.mimeType).toBe('image/png');
|
|
25
|
+
expect(uploadResult.url).toContain('https://cdn.example.com/files/');
|
|
26
|
+
expect(uploadResult.sizeBytes).toBeGreaterThan(0);
|
|
27
|
+
|
|
28
|
+
const downloadUrl = await store.getDownloadUrl(uploadResult.storageKey);
|
|
29
|
+
expect(downloadUrl).toBe(uploadResult.url);
|
|
30
|
+
|
|
31
|
+
const presigned = await store.getPresignedUploadUrl({
|
|
32
|
+
storageKey: 'presigned-key.png',
|
|
33
|
+
contentType: 'image/png'
|
|
34
|
+
});
|
|
35
|
+
expect(presigned.uploadUrl).toContain('presigned-key.png');
|
|
36
|
+
expect(presigned.method).toBe('PUT');
|
|
37
|
+
|
|
38
|
+
const deleted = await store.delete(uploadResult.storageKey);
|
|
39
|
+
expect(deleted).toBe(true);
|
|
40
|
+
await expect(store.getDownloadUrl(uploadResult.storageKey)).rejects.toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('S3StorageAdapter', () => {
|
|
45
|
+
it('generates correct URLs and handles duck-typed client calls', async () => {
|
|
46
|
+
let sentCommand: any = null;
|
|
47
|
+
const mockClient = {
|
|
48
|
+
send: async (cmd: any) => {
|
|
49
|
+
sentCommand = cmd;
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const s3 = new S3StorageAdapter({
|
|
55
|
+
bucket: 'my-project-bucket',
|
|
56
|
+
region: 'eu-west-1',
|
|
57
|
+
s3Client: mockClient
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const res = await s3.upload({
|
|
61
|
+
filename: 'spec.pdf',
|
|
62
|
+
data: new Uint8Array([1, 2, 3, 4]),
|
|
63
|
+
mimeType: 'application/pdf',
|
|
64
|
+
pathPrefix: 'docs'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(res.storageKey).toContain('docs/');
|
|
68
|
+
expect(res.storageKey).toContain('spec.pdf');
|
|
69
|
+
expect(res.url).toBe(`https://my-project-bucket.s3.eu-west-1.amazonaws.com/${res.storageKey}`);
|
|
70
|
+
expect(sentCommand).toBeDefined();
|
|
71
|
+
expect(sentCommand.input.Bucket).toBe('my-project-bucket');
|
|
72
|
+
|
|
73
|
+
const presigned = await s3.getPresignedUploadUrl({
|
|
74
|
+
storageKey: 'uploads/file.zip',
|
|
75
|
+
contentType: 'application/zip'
|
|
76
|
+
});
|
|
77
|
+
expect(presigned.uploadUrl).toContain('my-project-bucket');
|
|
78
|
+
expect(presigned.headers?.['Content-Type']).toBe('application/zip');
|
|
79
|
+
|
|
80
|
+
const deleted = await s3.delete(res.storageKey);
|
|
81
|
+
expect(deleted).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('FirebaseStorageAdapter & InMemoryFirebaseStorageMock', () => {
|
|
86
|
+
it('works with InMemoryFirebaseStorageMock', async () => {
|
|
87
|
+
const mockStorage = new InMemoryFirebaseStorageMock();
|
|
88
|
+
const adapter = new FirebaseStorageAdapter({
|
|
89
|
+
bucket: mockStorage,
|
|
90
|
+
bucketName: 'my-app.appspot.com'
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const uploadResult = await adapter.upload({
|
|
94
|
+
filename: 'architecture.png',
|
|
95
|
+
data: 'png-data-mock',
|
|
96
|
+
mimeType: 'image/png',
|
|
97
|
+
pathPrefix: 'projects/p1'
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(uploadResult.storageKey).toContain('projects/p1/');
|
|
101
|
+
expect(uploadResult.url).toContain('https://firebasestorage.googleapis.com/v0/b/my-app.appspot.com/o/');
|
|
102
|
+
|
|
103
|
+
const downloadUrl = await adapter.getDownloadUrl(uploadResult.storageKey);
|
|
104
|
+
expect(downloadUrl).toBe(uploadResult.url);
|
|
105
|
+
|
|
106
|
+
const deleted = await adapter.delete(uploadResult.storageKey);
|
|
107
|
+
expect(deleted).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('CriticalPathEngine with File Storage', () => {
|
|
112
|
+
it('creates attachments with direct upload & handles file deletion on attachment removal', async () => {
|
|
113
|
+
const fileStore = new InMemoryFileStore();
|
|
114
|
+
const engine = new CriticalPathEngine({ fileStorage: fileStore });
|
|
115
|
+
|
|
116
|
+
const project = await engine.createProject({ key: 'ATT', name: 'Attachment Project' });
|
|
117
|
+
const task = await engine.createTask({ projectId: project.id, title: 'Upload test task', status: 'todo' });
|
|
118
|
+
|
|
119
|
+
// Upload file directly through engine (which also creates attachment)
|
|
120
|
+
const attachment = await engine.uploadAttachmentFile({
|
|
121
|
+
filename: 'screenshot.png',
|
|
122
|
+
data: 'image-binary-bytes',
|
|
123
|
+
mimeType: 'image/png',
|
|
124
|
+
taskId: task.id,
|
|
125
|
+
projectId: project.id,
|
|
126
|
+
uploaderId: 'user_1',
|
|
127
|
+
uploaderType: 'user'
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(attachment.id).toBeDefined();
|
|
131
|
+
expect(attachment.filename).toBe('screenshot.png');
|
|
132
|
+
expect(attachment.storageKey).toBeDefined();
|
|
133
|
+
|
|
134
|
+
const taskAttachments = await engine.getAttachments({ taskId: task.id });
|
|
135
|
+
expect(taskAttachments.length).toBe(1);
|
|
136
|
+
expect(taskAttachments[0].id).toBe(attachment.id);
|
|
137
|
+
|
|
138
|
+
// Deleting attachment should also delete file from fileStorage
|
|
139
|
+
const deleted = await engine.deleteAttachment(attachment.id);
|
|
140
|
+
expect(deleted).toBe(true);
|
|
141
|
+
|
|
142
|
+
const remainingAttachments = await engine.getAttachments({ taskId: task.id });
|
|
143
|
+
expect(remainingAttachments.length).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -77,4 +77,19 @@ describe('FirebaseStore', () => {
|
|
|
77
77
|
const deleted = await store.deleteWorkflow(workflow.id);
|
|
78
78
|
expect(deleted).toBe(true);
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
it('should sanitize undefined properties from documents', async () => {
|
|
82
|
+
const project = await store.createProject({
|
|
83
|
+
key: 'UNDEF',
|
|
84
|
+
name: 'Undefined Test',
|
|
85
|
+
description: undefined,
|
|
86
|
+
ownerId: undefined
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const doc = await store.getProject(project.id);
|
|
90
|
+
expect(doc).toBeDefined();
|
|
91
|
+
expect(doc?.name).toBe('Undefined Test');
|
|
92
|
+
expect('description' in (doc as any)).toBe(false);
|
|
93
|
+
});
|
|
80
94
|
});
|
|
95
|
+
|