@proteinjs/db-file 1.8.3 → 1.9.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 +38 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/src/DbFileStorageDriver.d.ts +7 -0
- package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
- package/dist/src/DbFileStorageDriver.js +69 -41
- package/dist/src/DbFileStorageDriver.js.map +1 -1
- package/dist/src/FileCopyForOthers.d.ts +52 -0
- package/dist/src/FileCopyForOthers.d.ts.map +1 -0
- package/dist/src/FileCopyForOthers.js +62 -0
- package/dist/src/FileCopyForOthers.js.map +1 -0
- package/dist/src/FileStorage.d.ts +145 -11
- package/dist/src/FileStorage.d.ts.map +1 -1
- package/dist/src/FileStorage.js +537 -41
- package/dist/src/FileStorage.js.map +1 -1
- package/dist/src/FileStorageDriver.d.ts +11 -0
- package/dist/src/FileStorageDriver.d.ts.map +1 -1
- package/dist/src/FileStorageError.d.ts +62 -0
- package/dist/src/FileStorageError.d.ts.map +1 -0
- package/dist/src/FileStorageError.js +118 -0
- package/dist/src/FileStorageError.js.map +1 -0
- package/dist/src/FileVariantMaker.d.ts +61 -0
- package/dist/src/FileVariantMaker.d.ts.map +1 -0
- package/dist/src/FileVariantMaker.js +62 -0
- package/dist/src/FileVariantMaker.js.map +1 -0
- package/dist/src/routes/FileResponder.d.ts +41 -0
- package/dist/src/routes/FileResponder.d.ts.map +1 -0
- package/dist/src/routes/FileResponder.js +135 -0
- package/dist/src/routes/FileResponder.js.map +1 -0
- package/dist/src/routes/getFile.d.ts +5 -13
- package/dist/src/routes/getFile.d.ts.map +1 -1
- package/dist/src/routes/getFile.js +20 -56
- package/dist/src/routes/getFile.js.map +1 -1
- package/dist/src/routes/getFileVariant.d.ts +20 -0
- package/dist/src/routes/getFileVariant.d.ts.map +1 -0
- package/dist/src/routes/getFileVariant.js +112 -0
- package/dist/src/routes/getFileVariant.js.map +1 -0
- package/dist/src/routes/getFileVariantRoute.d.ts +7 -0
- package/dist/src/routes/getFileVariantRoute.d.ts.map +1 -0
- package/dist/src/routes/getFileVariantRoute.js +9 -0
- package/dist/src/routes/getFileVariantRoute.js.map +1 -0
- package/dist/src/tables/FileTable.d.ts +55 -2
- package/dist/src/tables/FileTable.d.ts.map +1 -1
- package/dist/src/tables/FileTable.js +8 -0
- package/dist/src/tables/FileTable.js.map +1 -1
- package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts +2 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts.map +1 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.js +184 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.js.map +1 -0
- package/dist/test/FileByteAccess.test.js +6 -3
- package/dist/test/FileByteAccess.test.js.map +1 -1
- package/dist/test/FileCopyForOthers.test.d.ts +2 -0
- package/dist/test/FileCopyForOthers.test.d.ts.map +1 -0
- package/dist/test/FileCopyForOthers.test.js +1143 -0
- package/dist/test/FileCopyForOthers.test.js.map +1 -0
- package/dist/test/FileMediaMetadata.test.js +60 -5
- package/dist/test/FileMediaMetadata.test.js.map +1 -1
- package/dist/test/FileStorageError.test.d.ts +2 -0
- package/dist/test/FileStorageError.test.d.ts.map +1 -0
- package/dist/test/FileStorageError.test.js +68 -0
- package/dist/test/FileStorageError.test.js.map +1 -0
- package/dist/test/FileTestEnvironment.d.ts.map +1 -1
- package/dist/test/FileTestEnvironment.js +3 -0
- package/dist/test/FileTestEnvironment.js.map +1 -1
- package/dist/test/FileVariants.test.d.ts +2 -0
- package/dist/test/FileVariants.test.d.ts.map +1 -0
- package/dist/test/FileVariants.test.js +1008 -0
- package/dist/test/FileVariants.test.js.map +1 -0
- package/dist/test/GetFileRoute.test.js +29 -0
- package/dist/test/GetFileRoute.test.js.map +1 -1
- package/generated/index.ts +3 -1
- package/index.ts +4 -0
- package/package.json +5 -5
- package/src/DbFileStorageDriver.ts +32 -21
- package/src/FileCopyForOthers.ts +71 -0
- package/src/FileStorage.ts +351 -18
- package/src/FileStorageDriver.ts +12 -0
- package/src/FileStorageError.ts +123 -0
- package/src/FileVariantMaker.ts +84 -0
- package/src/routes/FileResponder.ts +85 -0
- package/src/routes/getFile.ts +9 -44
- package/src/routes/getFileVariant.ts +53 -0
- package/src/routes/getFileVariantRoute.ts +7 -0
- package/src/tables/FileTable.ts +57 -1
- package/test/DbFileStorageDriverMissingBytes.test.ts +85 -0
- package/test/FileByteAccess.test.ts +7 -1
- package/test/FileCopyForOthers.test.ts +702 -0
- package/test/FileMediaMetadata.test.ts +44 -0
- package/test/FileStorageError.test.ts +79 -0
- package/test/FileTestEnvironment.ts +3 -0
- package/test/FileVariants.test.ts +604 -0
- package/test/GetFileRoute.test.ts +19 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { getDbAsSystem, QueryBuilderFactory } from '@proteinjs/db';
|
|
2
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
3
|
+
import { UserAuth, UserRepo, User, guestUser } from '@proteinjs/user';
|
|
4
|
+
import { File } from '../src/tables/FileTable';
|
|
5
|
+
import { tables } from '../src/tables/tables';
|
|
6
|
+
import { FileStorage } from '../src/FileStorage';
|
|
7
|
+
import { FileStorageDriver } from '../src/FileStorageDriver';
|
|
8
|
+
import { FileVariantKind } from '../src/FileVariantMaker';
|
|
9
|
+
import { getFile } from '../src/routes/getFile';
|
|
10
|
+
import { getFileVariant } from '../src/routes/getFileVariant';
|
|
11
|
+
import { getFileVariantRoute } from '../src/routes/getFileVariantRoute';
|
|
12
|
+
import { FileTestEnvironment } from './FileTestEnvironment';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A derived File of a File — the preview and the stage variant — has ONE lifecycle, the library's:
|
|
16
|
+
* made by the registered maker (at ingest with the bytes in hand, or once on the first request from
|
|
17
|
+
* a surface that draws it), stored as the owner's own File naming its original, named on the
|
|
18
|
+
* original's row, dropped when the original's bytes change, deleted with the original, readable by
|
|
19
|
+
* whoever can read the original, and served like any File — through the same non-owner copy rule.
|
|
20
|
+
* This suite proves the seam contract with a stub maker (the real one — a picture's 512 px thumbnail
|
|
21
|
+
* and its 1600 px rendition — lives in the package that knows pictures).
|
|
22
|
+
*
|
|
23
|
+
* The stub maker marks what it makes: `PREVIEW:`/`STAGE:` and the first bytes of the original.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const originalBytes = Buffer.concat([Buffer.from('ORIGINAL:'), Buffer.from(Array.from({ length: 64 }, (_, i) => i))]);
|
|
27
|
+
const variantOf = (kind: FileVariantKind, bytes: Buffer): Buffer =>
|
|
28
|
+
Buffer.concat([Buffer.from(`${kind.toUpperCase()}:`), bytes.subarray(0, 8)]);
|
|
29
|
+
const VARIANT_EDGE: Record<FileVariantKind, number> = { preview: 512, stage: 1600 };
|
|
30
|
+
/** What the copy-for-others maker takes off a copy (the location marker of the real one). */
|
|
31
|
+
const COPY_MARK = Buffer.from('-FOR-OTHERS');
|
|
32
|
+
|
|
33
|
+
/** In-memory proxy-shape driver (no signed URLs) — the DbFileStorageDriver serving shape. */
|
|
34
|
+
class ProxyDriver implements FileStorageDriver {
|
|
35
|
+
readonly store = new Map<string, Buffer>();
|
|
36
|
+
/** Every id whose bytes were read, in order — what a derivation costs the store. */
|
|
37
|
+
reads: string[] = [];
|
|
38
|
+
/** Every id whose bytes were deleted, in order. */
|
|
39
|
+
deletes: string[] = [];
|
|
40
|
+
|
|
41
|
+
async createFile(file: File, fileData: string): Promise<void> {
|
|
42
|
+
this.store.set(file.id, Buffer.from(fileData, 'base64'));
|
|
43
|
+
await atBarrier(); // the racing case: every variant's bytes are stored before any row is asked its word
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async getFileData(fileId: string): Promise<string> {
|
|
47
|
+
this.reads.push(fileId);
|
|
48
|
+
const data = this.store.get(fileId);
|
|
49
|
+
if (data === undefined) {
|
|
50
|
+
throw new Error(`No such object: ${fileId}`);
|
|
51
|
+
}
|
|
52
|
+
return data.toString('base64');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async updateFileData(fileId: string, data: string): Promise<void> {
|
|
56
|
+
this.store.set(fileId, Buffer.from(data, 'base64'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async deleteFile(fileId: string): Promise<void> {
|
|
60
|
+
this.deletes.push(fileId);
|
|
61
|
+
this.store.delete(fileId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class ResponseRecorder {
|
|
66
|
+
statusCode?: number;
|
|
67
|
+
body?: unknown;
|
|
68
|
+
headers: Record<string, string> = {};
|
|
69
|
+
|
|
70
|
+
status(code: number): ResponseRecorder {
|
|
71
|
+
this.statusCode = code;
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
send(body: unknown): ResponseRecorder {
|
|
76
|
+
this.body = body;
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setHeader(name: string, value: string): void {
|
|
81
|
+
this.headers[name.toLowerCase()] = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
redirect(): void {
|
|
85
|
+
throw new Error('the proxy driver never redirects');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type RouteRequest = Parameters<typeof getFile.onRequest>[0];
|
|
90
|
+
type RouteResponse = Parameters<typeof getFile.onRequest>[1];
|
|
91
|
+
|
|
92
|
+
const invokeRoute = async (fileId: string): Promise<ResponseRecorder> => {
|
|
93
|
+
const response = new ResponseRecorder();
|
|
94
|
+
await getFile.onRequest(
|
|
95
|
+
{ params: { id: fileId }, headers: {} } as unknown as RouteRequest,
|
|
96
|
+
response as unknown as RouteResponse
|
|
97
|
+
);
|
|
98
|
+
return response;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** `GET /file/:id/variant/:kind` — the URL a surface asks for a file's variant by (a plain `<img src>`). */
|
|
102
|
+
const invokeVariantRoute = async (fileId: string, kind: string): Promise<ResponseRecorder> => {
|
|
103
|
+
const response = new ResponseRecorder();
|
|
104
|
+
await getFileVariant.onRequest(
|
|
105
|
+
{ params: { id: fileId, kind }, headers: {} } as unknown as RouteRequest,
|
|
106
|
+
response as unknown as RouteResponse
|
|
107
|
+
);
|
|
108
|
+
return response;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const testEnv = new FileTestEnvironment();
|
|
112
|
+
type UserAuthInternals = { userRepo?: unknown };
|
|
113
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
114
|
+
/** The making itself, reached directly: what two PROCESSES do (each past its own in-flight check). */
|
|
115
|
+
type FileStorageInternals = { makeVariant(file: File, kind: FileVariantKind, bytes: Buffer): Promise<string> };
|
|
116
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
117
|
+
|
|
118
|
+
let owner: User;
|
|
119
|
+
let recipient: User;
|
|
120
|
+
let stranger: User;
|
|
121
|
+
const reachableFileIds = new Set<string>();
|
|
122
|
+
/** Every call the variant maker took: which file, which kind, the bytes it was handed. */
|
|
123
|
+
let makerCalls: Array<{ fileId: string; kind: FileVariantKind; bytes: Buffer }> = [];
|
|
124
|
+
/** The kinds the stub maker applies to (of a picture) — the suite's own rule. */
|
|
125
|
+
let kindsApplied: Set<FileVariantKind> = new Set<FileVariantKind>(['preview', 'stage']);
|
|
126
|
+
/** A barrier for the racing case — `makerBarrier = n` holds every maker call until n are inside, then releases them on one tick. */
|
|
127
|
+
let makerBarrier = 0;
|
|
128
|
+
let makerWaiting: Array<() => void> = [];
|
|
129
|
+
/** A hold for the in-flight case — the maker waits on it (when set) so readers can arrive while one derivation runs. */
|
|
130
|
+
let makerHold: Promise<void> | undefined;
|
|
131
|
+
/** The maker cannot make anything of these bytes (a picture the decoder cannot read). */
|
|
132
|
+
let makerRefuses = false;
|
|
133
|
+
const atBarrier = async (): Promise<void> => {
|
|
134
|
+
if (makerBarrier <= 0) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await new Promise<void>((resolve) => {
|
|
138
|
+
makerWaiting.push(resolve);
|
|
139
|
+
if (makerWaiting.length >= makerBarrier) {
|
|
140
|
+
const release = makerWaiting;
|
|
141
|
+
makerWaiting = [];
|
|
142
|
+
release.forEach((go) => go());
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const registerVariantMaker = () => {
|
|
148
|
+
objectCache()['@proteinjs/db-file/FileVariantMaker'] = [
|
|
149
|
+
{
|
|
150
|
+
appliesTo: (file: File, kind: FileVariantKind) => file.type.startsWith('image/') && kindsApplied.has(kind),
|
|
151
|
+
make: async (file: File, bytes: Buffer, kind: FileVariantKind) => {
|
|
152
|
+
makerCalls.push({ fileId: file.id, kind, bytes: Buffer.from(bytes) });
|
|
153
|
+
await atBarrier();
|
|
154
|
+
await makerHold;
|
|
155
|
+
if (makerRefuses) {
|
|
156
|
+
throw new Error('the decoder cannot decode these bytes');
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
bytes: variantOf(kind, bytes),
|
|
160
|
+
type: 'image/webp',
|
|
161
|
+
width: VARIANT_EDGE[kind],
|
|
162
|
+
height: VARIANT_EDGE[kind] / 2,
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** The copy-for-others maker of the copy suite, in miniature: a picture's copy is its bytes with the mark taken off. */
|
|
170
|
+
let copyMakerCalls: string[] = [];
|
|
171
|
+
const registerCopyMaker = () => {
|
|
172
|
+
objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [
|
|
173
|
+
{
|
|
174
|
+
appliesTo: (file: File) => file.type.startsWith('image/'),
|
|
175
|
+
make: async (file: File, bytes: Buffer) => {
|
|
176
|
+
copyMakerCalls.push(file.id);
|
|
177
|
+
return bytes.subarray(0, bytes.length - COPY_MARK.length);
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
beforeAll(async () => {
|
|
184
|
+
await testEnv.beforeAll();
|
|
185
|
+
objectCache()['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
|
|
186
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
187
|
+
objectCache()['@proteinjs/db-file/FileReachabilityResolver'] = [
|
|
188
|
+
// The content leg vouches for the RECIPIENT alone (a share they accepted); a stranger holds no grant.
|
|
189
|
+
{
|
|
190
|
+
canReadViaReference: async (fileId: string) =>
|
|
191
|
+
reachableFileIds.has(fileId) && new UserRepo().getUser().id === recipient.id,
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
owner = await testEnv.createUser({ name: 'File owner', email: 'variant-owner@test.local' });
|
|
195
|
+
recipient = await testEnv.createUser({ name: 'Share recipient', email: 'variant-recipient@test.local' });
|
|
196
|
+
stranger = await testEnv.createUser({ name: 'Stranger', email: 'variant-stranger@test.local' });
|
|
197
|
+
testEnv.actAs(owner);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
afterAll(async () => {
|
|
201
|
+
delete objectCache()['@proteinjs/db-file/FileReachabilityResolver'];
|
|
202
|
+
delete objectCache()['@proteinjs/db-file/FileVariantMaker'];
|
|
203
|
+
delete objectCache()['@proteinjs/db-file/FileCopyForOthers'];
|
|
204
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
205
|
+
await testEnv.afterAll();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const driver = new ProxyDriver();
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
reachableFileIds.clear();
|
|
212
|
+
makerCalls = [];
|
|
213
|
+
copyMakerCalls = [];
|
|
214
|
+
kindsApplied = new Set<FileVariantKind>(['preview', 'stage']);
|
|
215
|
+
makerBarrier = 0;
|
|
216
|
+
makerWaiting = [];
|
|
217
|
+
makerHold = undefined;
|
|
218
|
+
makerRefuses = false;
|
|
219
|
+
driver.reads = [];
|
|
220
|
+
driver.deletes = [];
|
|
221
|
+
registerVariantMaker();
|
|
222
|
+
objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [];
|
|
223
|
+
testEnv.setDriver(driver);
|
|
224
|
+
testEnv.actAs(owner);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/** A picture of the owner's, stored with no variant (a file made before the seam, or by a producer that did not derive one). */
|
|
228
|
+
const createOwnerPicture = async (name = 'photo.jpg', type = 'image/jpeg', bytes = originalBytes): Promise<File> =>
|
|
229
|
+
await new FileStorage().createFile(
|
|
230
|
+
{ name, type, size: bytes.length, width: 4032, height: 3024 } as File,
|
|
231
|
+
bytes.toString('base64')
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const rowAsSystem = async (fileId: string): Promise<File | undefined> =>
|
|
235
|
+
await getDbAsSystem().get(tables.File, { id: fileId });
|
|
236
|
+
|
|
237
|
+
const fileRowsInScope = async (scope: string): Promise<File[]> =>
|
|
238
|
+
await getDbAsSystem().query(
|
|
239
|
+
tables.File,
|
|
240
|
+
new QueryBuilderFactory().getQueryBuilder(tables.File).condition({ field: 'scope', operator: '=', value: scope })
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
describe('the ingest door — deriveVariants with the bytes in hand', () => {
|
|
244
|
+
it("makes every kind the maker applies to, beside each other: two Files of the OWNER's, each naming its original, each named on the row", async () => {
|
|
245
|
+
const file = await createOwnerPicture('camera.jpg');
|
|
246
|
+
|
|
247
|
+
const derived = await new FileStorage().deriveVariants(file, originalBytes);
|
|
248
|
+
|
|
249
|
+
expect(makerCalls.map((call) => [call.fileId, call.kind])).toEqual([
|
|
250
|
+
[file.id, 'preview'],
|
|
251
|
+
[file.id, 'stage'],
|
|
252
|
+
]);
|
|
253
|
+
expect(makerCalls.every((call) => call.bytes.equals(originalBytes))).toBe(true);
|
|
254
|
+
const row = (await rowAsSystem(file.id))!;
|
|
255
|
+
expect(row.preview?._id).toEqual(derived.variants.preview!.id);
|
|
256
|
+
expect(row.stage?._id).toEqual(derived.variants.stage!.id);
|
|
257
|
+
expect(derived.file.preview?._id).toEqual(row.preview?._id);
|
|
258
|
+
expect(derived.file.stage?._id).toEqual(row.stage?._id);
|
|
259
|
+
for (const kind of ['preview', 'stage'] as const) {
|
|
260
|
+
const variant = (await rowAsSystem(derived.variants[kind]!.id))!;
|
|
261
|
+
expect(variant.scope).toEqual(owner.id);
|
|
262
|
+
expect(variant.variantOf?._id).toEqual(file.id);
|
|
263
|
+
expect([variant.name, variant.type, variant.size, variant.width, variant.height]).toEqual([
|
|
264
|
+
`(${kind}) camera.jpg`,
|
|
265
|
+
'image/webp',
|
|
266
|
+
variantOf(kind, originalBytes).length,
|
|
267
|
+
VARIANT_EDGE[kind],
|
|
268
|
+
VARIANT_EDGE[kind] / 2,
|
|
269
|
+
]);
|
|
270
|
+
expect(driver.store.get(variant.id)!.equals(variantOf(kind, originalBytes))).toBe(true);
|
|
271
|
+
}
|
|
272
|
+
// The original is untouched: its bytes, its size, its own seat empty.
|
|
273
|
+
expect(driver.store.get(file.id)!.equals(originalBytes)).toBe(true);
|
|
274
|
+
expect(row.variantOf?._id ?? null).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('a kind the maker does not apply to is not made and its seat stays empty; a kind already named is not made again; with no maker nothing is made', async () => {
|
|
278
|
+
kindsApplied = new Set<FileVariantKind>(['preview']);
|
|
279
|
+
const file = await createOwnerPicture('small.png', 'image/png');
|
|
280
|
+
|
|
281
|
+
const first = await new FileStorage().deriveVariants(file, originalBytes);
|
|
282
|
+
expect(Object.keys(first.variants)).toEqual(['preview']);
|
|
283
|
+
expect((await rowAsSystem(file.id))!.stage?._id ?? null).toBeNull();
|
|
284
|
+
|
|
285
|
+
kindsApplied = new Set<FileVariantKind>(['preview', 'stage']);
|
|
286
|
+
makerCalls = [];
|
|
287
|
+
const second = await new FileStorage().deriveVariants(first.file, originalBytes);
|
|
288
|
+
expect(makerCalls.map((call) => call.kind)).toEqual(['stage']);
|
|
289
|
+
expect(second.file.preview?._id).toEqual(first.variants.preview!.id);
|
|
290
|
+
expect(second.file.stage?._id).toEqual(second.variants.stage!.id);
|
|
291
|
+
|
|
292
|
+
objectCache()['@proteinjs/db-file/FileVariantMaker'] = [];
|
|
293
|
+
const bare = await createOwnerPicture('bare.jpg');
|
|
294
|
+
const none = await new FileStorage().deriveVariants(bare, originalBytes);
|
|
295
|
+
expect(none.variants).toEqual({});
|
|
296
|
+
expect((await rowAsSystem(bare.id))!.stage?._id ?? null).toBeNull();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('the lifecycle — the stage dies with its original and with its bytes, exactly as the preview does', () => {
|
|
301
|
+
it('deleting the original deletes the stage variant: its row and its bytes (the cascade)', async () => {
|
|
302
|
+
const file = await createOwnerPicture('gone.jpg');
|
|
303
|
+
const { variants } = await new FileStorage().deriveVariants(file, originalBytes);
|
|
304
|
+
expect(driver.store.has(variants.stage!.id)).toBe(true);
|
|
305
|
+
|
|
306
|
+
await new FileStorage().deleteFile(file.id);
|
|
307
|
+
|
|
308
|
+
expect(await rowAsSystem(variants.stage!.id)).toBeUndefined();
|
|
309
|
+
expect(driver.store.has(variants.stage!.id)).toBe(false);
|
|
310
|
+
expect(await rowAsSystem(variants.preview!.id)).toBeUndefined();
|
|
311
|
+
expect(driver.store.has(variants.preview!.id)).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('new bytes drop every derived File — the preview, the stage and the copy for others: the row forgets them, their rows and bytes go', async () => {
|
|
315
|
+
registerCopyMaker();
|
|
316
|
+
const file = await createOwnerPicture('rewritten.jpg');
|
|
317
|
+
const { variants } = await new FileStorage().deriveVariants(file, originalBytes);
|
|
318
|
+
reachableFileIds.add(file.id);
|
|
319
|
+
testEnv.actAs(recipient);
|
|
320
|
+
await new FileStorage().getFileData(file.id); // the copy for others is made and named
|
|
321
|
+
const before = (await rowAsSystem(file.id))!;
|
|
322
|
+
expect(before.copyForOthers?._id).toBeTruthy();
|
|
323
|
+
|
|
324
|
+
testEnv.actAs(owner);
|
|
325
|
+
const newBytes = Buffer.from('NEW-BYTES');
|
|
326
|
+
await new FileStorage().updateFileData(file.id, newBytes.toString('base64'));
|
|
327
|
+
|
|
328
|
+
const after = (await rowAsSystem(file.id))!;
|
|
329
|
+
expect([after.preview?._id ?? null, after.stage?._id ?? null, after.copyForOthers?._id ?? null]).toEqual([
|
|
330
|
+
null,
|
|
331
|
+
null,
|
|
332
|
+
null,
|
|
333
|
+
]);
|
|
334
|
+
for (const id of [variants.preview!.id, variants.stage!.id, before.copyForOthers!._id!]) {
|
|
335
|
+
expect(await rowAsSystem(id)).toBeUndefined();
|
|
336
|
+
expect(driver.store.has(id)).toBe(false);
|
|
337
|
+
}
|
|
338
|
+
expect(driver.store.get(file.id)!.equals(newBytes)).toBe(true);
|
|
339
|
+
// The next request derives a fresh stage from the new bytes.
|
|
340
|
+
const fresh = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
341
|
+
expect(driver.store.get(fresh.id)!.equals(variantOf('stage', newBytes))).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('the read path — a File without a stage derives it on the first request, once', () => {
|
|
346
|
+
it('the first getVariant makes the stage and names it; the second is served the same row without the maker', async () => {
|
|
347
|
+
const file = await createOwnerPicture('existing.jpg');
|
|
348
|
+
expect((await rowAsSystem(file.id))!.stage?._id ?? null).toBeNull();
|
|
349
|
+
|
|
350
|
+
const first = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
351
|
+
const second = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
352
|
+
|
|
353
|
+
expect(makerCalls.map((call) => call.kind)).toEqual(['stage']);
|
|
354
|
+
expect(makerCalls[0].bytes.equals(originalBytes)).toBe(true);
|
|
355
|
+
expect(second.id).toEqual(first.id);
|
|
356
|
+
expect((await rowAsSystem(file.id))!.stage?._id).toEqual(first.id);
|
|
357
|
+
expect(first.variantOf?._id).toEqual(file.id);
|
|
358
|
+
expect([first.type, first.width, first.height]).toEqual(['image/webp', 1600, 800]);
|
|
359
|
+
expect(driver.store.get(first.id)!.equals(variantOf('stage', originalBytes))).toBe(true);
|
|
360
|
+
// Only the kind asked for: the preview seat stays empty.
|
|
361
|
+
expect((await rowAsSystem(file.id))!.preview?._id ?? null).toBeNull();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("a recipient's read derives it as the OWNER's File; the owner is then served the same row", async () => {
|
|
365
|
+
const file = await createOwnerPicture('shared.jpg');
|
|
366
|
+
reachableFileIds.add(file.id);
|
|
367
|
+
|
|
368
|
+
testEnv.actAs(recipient);
|
|
369
|
+
const theirs = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
370
|
+
testEnv.actAs(owner);
|
|
371
|
+
const mine = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
372
|
+
|
|
373
|
+
expect(theirs.scope).toEqual(owner.id);
|
|
374
|
+
expect(mine.id).toEqual(theirs.id);
|
|
375
|
+
expect(makerCalls).toHaveLength(1);
|
|
376
|
+
expect((await fileRowsInScope(recipient.id)).map((row) => row.id)).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('a kind the maker does not apply to makes nothing and answers undefined; a caller who cannot read the original gets undefined and nothing is made', async () => {
|
|
380
|
+
const clip = await createOwnerPicture('clip.mp4', 'video/mp4');
|
|
381
|
+
expect(await new FileStorage().getVariant(clip.id, 'stage')).toBeUndefined();
|
|
382
|
+
expect((await rowAsSystem(clip.id))!.stage?._id ?? null).toBeNull();
|
|
383
|
+
|
|
384
|
+
const photo = await createOwnerPicture('private.jpg');
|
|
385
|
+
testEnv.actAs(stranger);
|
|
386
|
+
expect(await new FileStorage().getVariant(photo.id, 'stage')).toBeUndefined();
|
|
387
|
+
expect(makerCalls).toEqual([]);
|
|
388
|
+
expect((await rowAsSystem(photo.id))!.stage?._id ?? null).toBeNull();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('two PROCESSES deriving at once (the row is the only thing they share): the row names one stage, the other is deleted with its bytes', async () => {
|
|
392
|
+
const file = await createOwnerPicture('raced.jpg');
|
|
393
|
+
const storedBefore = driver.store.size;
|
|
394
|
+
makerBarrier = 2;
|
|
395
|
+
|
|
396
|
+
// Two servers derive the same stage on one tick: each has read the bytes and reaches the
|
|
397
|
+
// making directly — nothing in one process can see the other's derivation in flight.
|
|
398
|
+
const storage = new FileStorage() as unknown as FileStorageInternals;
|
|
399
|
+
const [a, b] = await Promise.all([
|
|
400
|
+
storage.makeVariant(file, 'stage', originalBytes),
|
|
401
|
+
storage.makeVariant(file, 'stage', originalBytes),
|
|
402
|
+
]);
|
|
403
|
+
|
|
404
|
+
expect(a).toEqual(b);
|
|
405
|
+
const named = (await rowAsSystem(file.id))!.stage!._id!;
|
|
406
|
+
expect(named).toEqual(a);
|
|
407
|
+
const stagesOfFile = (await fileRowsInScope(owner.id)).filter((row) => row.variantOf?._id === file.id);
|
|
408
|
+
expect(stagesOfFile.map((row) => row.id)).toEqual([named]);
|
|
409
|
+
// One stage's bytes remain of the two made: the loser's row went, and its bytes with it.
|
|
410
|
+
expect(driver.store.size).toEqual(storedBefore + 1);
|
|
411
|
+
expect(driver.store.has(named)).toBe(true);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('N readers asking one process for the same absent stage while it derives cost ONE derivation: one read of the original, one maker call, one object stored, no loser to delete; every reader is served the same row', async () => {
|
|
415
|
+
const file = await createOwnerPicture('burst.jpg');
|
|
416
|
+
const storedBefore = driver.store.size;
|
|
417
|
+
const readsBefore = driver.reads.filter((id) => id === file.id).length;
|
|
418
|
+
let release!: () => void;
|
|
419
|
+
makerHold = new Promise<void>((resolve) => (release = resolve));
|
|
420
|
+
|
|
421
|
+
const readers = Array.from({ length: 4 }, () => new FileStorage().getVariant(file.id, 'stage'));
|
|
422
|
+
// The first reader is inside the maker; the others have arrived while it derives.
|
|
423
|
+
while (makerCalls.length < 1) {
|
|
424
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
425
|
+
}
|
|
426
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
427
|
+
release();
|
|
428
|
+
const served = await Promise.all(readers);
|
|
429
|
+
|
|
430
|
+
expect(makerCalls).toHaveLength(1);
|
|
431
|
+
expect(driver.reads.filter((id) => id === file.id).length).toEqual(readsBefore + 1);
|
|
432
|
+
const named = (await rowAsSystem(file.id))!.stage!._id!;
|
|
433
|
+
expect(served.map((row) => row!.id)).toEqual([named, named, named, named]);
|
|
434
|
+
expect(driver.store.size).toEqual(storedBefore + 1);
|
|
435
|
+
expect(driver.deletes).toEqual([]);
|
|
436
|
+
// The next reader, after the derivation, is served the row without the maker.
|
|
437
|
+
expect((await new FileStorage().getVariant(file.id, 'stage'))!.id).toEqual(named);
|
|
438
|
+
expect(makerCalls).toHaveLength(1);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('a maker that cannot make the stage of these bytes on the read path: nothing is stored, the reader is answered undefined (the file itself is drawn), the next reader asks the maker again', async () => {
|
|
442
|
+
const file = await createOwnerPicture('unreadable.jpg');
|
|
443
|
+
makerRefuses = true;
|
|
444
|
+
|
|
445
|
+
expect(await new FileStorage().getVariant(file.id, 'stage')).toBeUndefined();
|
|
446
|
+
|
|
447
|
+
expect(makerCalls).toHaveLength(1);
|
|
448
|
+
expect((await rowAsSystem(file.id))!.stage?._id ?? null).toBeNull();
|
|
449
|
+
expect((await fileRowsInScope(owner.id)).filter((row) => row.variantOf?._id === file.id)).toEqual([]);
|
|
450
|
+
// Through the route: the file itself, its own headers — never a 500.
|
|
451
|
+
const served = await invokeVariantRoute(file.id, 'stage');
|
|
452
|
+
expect(served.statusCode).toBeUndefined();
|
|
453
|
+
expect((served.body as Buffer).equals(originalBytes)).toBe(true);
|
|
454
|
+
expect(served.headers['content-type']).toEqual('image/jpeg');
|
|
455
|
+
expect(makerCalls).toHaveLength(2);
|
|
456
|
+
// The ingest door is not softened: the maker's failure reaches the caller that holds the bytes.
|
|
457
|
+
await expect(new FileStorage().deriveVariants(file, originalBytes)).rejects.toThrow('cannot decode');
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('access — a variant is readable by whoever can read its original, and served like any File', () => {
|
|
462
|
+
it('a stage derived after the content was placed is reachable by its own id through its original: the recipient reads it, a stranger cannot', async () => {
|
|
463
|
+
const file = await createOwnerPicture('placed.jpg');
|
|
464
|
+
reachableFileIds.add(file.id); // the content row names the ORIGINAL only — nothing names the stage
|
|
465
|
+
testEnv.actAs(recipient);
|
|
466
|
+
const stage = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
467
|
+
|
|
468
|
+
expect((await new FileStorage().getFile(stage.id))?.id).toEqual(stage.id);
|
|
469
|
+
expect((await invokeRoute(stage.id)).statusCode).toBeUndefined(); // a plain 200 send: no status set
|
|
470
|
+
expect(Buffer.isBuffer((await invokeRoute(stage.id)).body)).toBe(true);
|
|
471
|
+
|
|
472
|
+
testEnv.actAs(stranger);
|
|
473
|
+
expect(await new FileStorage().getFile(stage.id)).toBeUndefined();
|
|
474
|
+
expect((await invokeRoute(stage.id)).statusCode).toEqual(404);
|
|
475
|
+
|
|
476
|
+
// Revoked: the moment the original is out of reach, so is its variant.
|
|
477
|
+
reachableFileIds.delete(file.id);
|
|
478
|
+
testEnv.actAs(recipient);
|
|
479
|
+
expect(await new FileStorage().getFile(stage.id)).toBeUndefined();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('the non-owner copy rule applies to the stage automatically: a recipient is served the copy of the STAGE, made once and named on the stage row; the owner the stage itself', async () => {
|
|
483
|
+
registerCopyMaker();
|
|
484
|
+
const file = await createOwnerPicture('located.jpg', 'image/jpeg', Buffer.concat([originalBytes, COPY_MARK]));
|
|
485
|
+
reachableFileIds.add(file.id);
|
|
486
|
+
// The stub variant maker keeps the mark on what it makes (the real one keeps a picture's tags for its owner).
|
|
487
|
+
objectCache()['@proteinjs/db-file/FileVariantMaker'] = [
|
|
488
|
+
{
|
|
489
|
+
appliesTo: (candidate: File, kind: FileVariantKind) => candidate.type.startsWith('image/') && kind === 'stage',
|
|
490
|
+
make: async (candidate: File, bytes: Buffer) => ({
|
|
491
|
+
bytes: Buffer.concat([Buffer.from('STAGE:'), bytes.subarray(0, 8), COPY_MARK]),
|
|
492
|
+
type: 'image/webp',
|
|
493
|
+
}),
|
|
494
|
+
},
|
|
495
|
+
];
|
|
496
|
+
const stage = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
497
|
+
const stageBytes = driver.store.get(stage.id)!;
|
|
498
|
+
expect(stageBytes.includes(COPY_MARK)).toBe(true);
|
|
499
|
+
|
|
500
|
+
testEnv.actAs(recipient);
|
|
501
|
+
const served = Buffer.from(await new FileStorage().getFileData(stage.id), 'base64');
|
|
502
|
+
const routed = (await invokeRoute(stage.id)).body as Buffer;
|
|
503
|
+
const again = Buffer.from(await new FileStorage().getFileData(stage.id), 'base64');
|
|
504
|
+
testEnv.actAs(owner);
|
|
505
|
+
const ownersOwn = Buffer.from(await new FileStorage().getFileData(stage.id), 'base64');
|
|
506
|
+
|
|
507
|
+
expect(served.includes(COPY_MARK)).toBe(false);
|
|
508
|
+
expect(served.equals(stageBytes.subarray(0, stageBytes.length - COPY_MARK.length))).toBe(true);
|
|
509
|
+
expect(routed.equals(served)).toBe(true);
|
|
510
|
+
expect(again.equals(served)).toBe(true);
|
|
511
|
+
expect(copyMakerCalls).toEqual([stage.id]);
|
|
512
|
+
const stageRow = (await rowAsSystem(stage.id))!;
|
|
513
|
+
expect(stageRow.copyForOthers?._id).toBeTruthy();
|
|
514
|
+
const copy = (await rowAsSystem(stageRow.copyForOthers!._id!))!;
|
|
515
|
+
expect(copy.scope).toEqual(owner.id);
|
|
516
|
+
expect(copy.variantOf?._id ?? null).toBeNull(); // the copy's own id stays nobody's but the owner's
|
|
517
|
+
testEnv.actAs(recipient);
|
|
518
|
+
expect(await new FileStorage().getFile(copy.id)).toBeUndefined();
|
|
519
|
+
expect(ownersOwn.equals(stageBytes)).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe('the route — GET /file/:id/variant/:kind is the one URL a surface asks a variant by (an existing row derives on the first request)', () => {
|
|
524
|
+
it('names its path as the file route does', () => {
|
|
525
|
+
expect(getFileVariantRoute.path('abc', 'stage')).toEqual('/file/abc/variant/stage');
|
|
526
|
+
expect(getFileVariant.path).toEqual('/file/:id/variant/:kind');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("the first request derives the stage and serves ITS bytes with ITS headers; the second is served the same row without the maker; the original's own route is untouched", async () => {
|
|
530
|
+
const file = await createOwnerPicture('old-row.jpg');
|
|
531
|
+
|
|
532
|
+
const first = await invokeVariantRoute(file.id, 'stage');
|
|
533
|
+
const second = await invokeVariantRoute(file.id, 'stage');
|
|
534
|
+
|
|
535
|
+
expect(makerCalls.map((call) => call.kind)).toEqual(['stage']);
|
|
536
|
+
const stageId = (await rowAsSystem(file.id))!.stage!._id!;
|
|
537
|
+
for (const served of [first, second]) {
|
|
538
|
+
expect(served.statusCode).toBeUndefined();
|
|
539
|
+
expect((served.body as Buffer).equals(variantOf('stage', originalBytes))).toBe(true);
|
|
540
|
+
expect(served.headers['content-type']).toEqual('image/webp');
|
|
541
|
+
expect(served.headers['content-disposition']).toEqual(
|
|
542
|
+
`inline; filename="${encodeURIComponent(`(stage) old-row.jpg`)}"`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
expect((await rowAsSystem(stageId))!.variantOf?._id).toEqual(file.id);
|
|
546
|
+
const original = await invokeRoute(file.id);
|
|
547
|
+
expect((original.body as Buffer).equals(originalBytes)).toBe(true);
|
|
548
|
+
expect(original.headers['content-type']).toEqual('image/jpeg');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('a file the maker does not apply to is served as ITSELF through the variant route (the consumer always gets a picture); nothing is made', async () => {
|
|
552
|
+
const clip = await createOwnerPicture('clip.mp4', 'video/mp4');
|
|
553
|
+
|
|
554
|
+
const served = await invokeVariantRoute(clip.id, 'stage');
|
|
555
|
+
|
|
556
|
+
expect(served.statusCode).toBeUndefined();
|
|
557
|
+
expect((served.body as Buffer).equals(originalBytes)).toBe(true);
|
|
558
|
+
expect(served.headers['content-type']).toEqual('video/mp4');
|
|
559
|
+
expect(makerCalls).toEqual([]);
|
|
560
|
+
expect((await rowAsSystem(clip.id))!.stage?._id ?? null).toBeNull();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('a kind the library does not know, a file the caller cannot read, and no session each answer without a byte', async () => {
|
|
564
|
+
const photo = await createOwnerPicture('private.jpg');
|
|
565
|
+
expect((await invokeVariantRoute(photo.id, 'poster')).statusCode).toEqual(404);
|
|
566
|
+
expect(makerCalls).toEqual([]);
|
|
567
|
+
|
|
568
|
+
testEnv.actAs(stranger);
|
|
569
|
+
expect((await invokeVariantRoute(photo.id, 'stage')).statusCode).toEqual(404);
|
|
570
|
+
expect(makerCalls).toEqual([]);
|
|
571
|
+
expect((await rowAsSystem(photo.id))!.stage?._id ?? null).toBeNull();
|
|
572
|
+
|
|
573
|
+
testEnv.actAs(guestUser as unknown as User);
|
|
574
|
+
expect((await invokeVariantRoute(photo.id, 'stage')).statusCode).toEqual(401);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("a recipient's request through the route derives the stage as the OWNER's File and is served the COPY of it (the non-owner rule, on the route); the owner the stage itself", async () => {
|
|
578
|
+
registerCopyMaker();
|
|
579
|
+
const file = await createOwnerPicture('shared-old.jpg', 'image/jpeg', Buffer.concat([originalBytes, COPY_MARK]));
|
|
580
|
+
reachableFileIds.add(file.id);
|
|
581
|
+
objectCache()['@proteinjs/db-file/FileVariantMaker'] = [
|
|
582
|
+
{
|
|
583
|
+
appliesTo: (candidate: File, kind: FileVariantKind) => candidate.type.startsWith('image/') && kind === 'stage',
|
|
584
|
+
make: async (candidate: File, bytes: Buffer) => ({
|
|
585
|
+
bytes: Buffer.concat([Buffer.from('STAGE:'), bytes.subarray(0, 8), COPY_MARK]),
|
|
586
|
+
type: 'image/webp',
|
|
587
|
+
}),
|
|
588
|
+
},
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
testEnv.actAs(recipient);
|
|
592
|
+
const theirs = await invokeVariantRoute(file.id, 'stage');
|
|
593
|
+
testEnv.actAs(owner);
|
|
594
|
+
const mine = await invokeVariantRoute(file.id, 'stage');
|
|
595
|
+
|
|
596
|
+
const stageRow = (await rowAsSystem((await rowAsSystem(file.id))!.stage!._id!))!;
|
|
597
|
+
expect(stageRow.scope).toEqual(owner.id);
|
|
598
|
+
expect((theirs.body as Buffer).includes(COPY_MARK)).toBe(false);
|
|
599
|
+
expect((mine.body as Buffer).includes(COPY_MARK)).toBe(true);
|
|
600
|
+
expect((mine.body as Buffer).equals(driver.store.get(stageRow.id)!)).toBe(true);
|
|
601
|
+
expect(copyMakerCalls).toEqual([stageRow.id]);
|
|
602
|
+
expect((await fileRowsInScope(recipient.id)).map((row) => row.id)).toEqual([]);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
@@ -5,6 +5,7 @@ import { UserAuth, UserRepo, guestUser, User } from '@proteinjs/user';
|
|
|
5
5
|
import { File } from '../src/tables/FileTable';
|
|
6
6
|
import { FileStorage } from '../src/FileStorage';
|
|
7
7
|
import { FileStorageDriver } from '../src/FileStorageDriver';
|
|
8
|
+
import { DbFileStorageDriver } from '../src/DbFileStorageDriver';
|
|
8
9
|
import { getFile } from '../src/routes/getFile';
|
|
9
10
|
import { FileTestEnvironment } from './FileTestEnvironment';
|
|
10
11
|
|
|
@@ -255,6 +256,24 @@ describe('GET /file/:id — proxy serving (driver without signed URLs)', () => {
|
|
|
255
256
|
}
|
|
256
257
|
});
|
|
257
258
|
});
|
|
259
|
+
|
|
260
|
+
it('404s when the row is there and its bytes are not — the driver’s not-found, never a 500', async () => {
|
|
261
|
+
testEnv.setDriver(new DbFileStorageDriver());
|
|
262
|
+
try {
|
|
263
|
+
const file = await new FileStorage().createFile(
|
|
264
|
+
{ name: 'lost.png', type: 'image/png', size: rawBytes.length } as File,
|
|
265
|
+
rawBytes.toString('base64')
|
|
266
|
+
);
|
|
267
|
+
await FileStorage.getDriver().deleteFile(file.id);
|
|
268
|
+
|
|
269
|
+
const response = await invokeRoute(file.id);
|
|
270
|
+
|
|
271
|
+
expect(response.statusCode).toEqual(404);
|
|
272
|
+
expect(response.body).toEqual('File not found');
|
|
273
|
+
} finally {
|
|
274
|
+
testEnv.setDriver(driver);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
258
277
|
});
|
|
259
278
|
|
|
260
279
|
describe('GET /file/:id — signed-URL serving (302 redirect)', () => {
|