@lenne.tech/nest-server 11.32.2 → 11.32.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude/rules/configurable-features.md +1 -1
  2. package/FRAMEWORK-API.md +3 -1
  3. package/bin/migrate.js +13 -3
  4. package/dist/core/common/helpers/file.helper.d.ts +14 -2
  5. package/dist/core/common/helpers/file.helper.js +48 -9
  6. package/dist/core/common/helpers/file.helper.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
  8. package/dist/core/modules/ai/inputs/core-ai-connection.input.js +2 -0
  9. package/dist/core/modules/ai/inputs/core-ai-connection.input.js.map +1 -1
  10. package/dist/core/modules/ai/services/core-ai-connection.service.d.ts +1 -0
  11. package/dist/core/modules/ai/services/core-ai-connection.service.js +68 -0
  12. package/dist/core/modules/ai/services/core-ai-connection.service.js.map +1 -1
  13. package/dist/core/modules/file/core-file.controller.d.ts +4 -1
  14. package/dist/core/modules/file/core-file.controller.js +39 -6
  15. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  16. package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
  17. package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
  18. package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
  19. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +1 -0
  20. package/dist/core/modules/migrate/helpers/migration.helper.js +51 -4
  21. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  22. package/dist/tsconfig.build.tsbuildinfo +1 -1
  23. package/docs/security-overrides.md +9 -2
  24. package/migration-guides/11.32.2-to-11.32.3.md +129 -0
  25. package/migration-guides/11.32.3-to-11.32.4.md +323 -0
  26. package/package.json +1 -1
  27. package/src/core/common/helpers/file.helper.spec.ts +145 -0
  28. package/src/core/common/helpers/file.helper.ts +148 -10
  29. package/src/core/common/interfaces/server-options.interface.ts +23 -0
  30. package/src/core/modules/ai/README.md +6 -0
  31. package/src/core/modules/ai/inputs/core-ai-connection.input.ts +2 -0
  32. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +18 -3
  33. package/src/core/modules/ai/services/core-ai-connection.service.ts +135 -0
  34. package/src/core/modules/file/README.md +59 -0
  35. package/src/core/modules/file/core-file.controller.spec.ts +164 -0
  36. package/src/core/modules/file/core-file.controller.ts +100 -8
  37. package/src/core/modules/migrate/README.md +35 -0
  38. package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
  39. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +85 -0
  40. package/src/core/modules/migrate/helpers/migration.helper.ts +131 -4
@@ -0,0 +1,164 @@
1
+ import { NotFoundException } from '@nestjs/common';
2
+ import type { Response } from 'express';
3
+ import { PassThrough, Readable } from 'stream';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import type { CoreFileService } from './core-file.service';
7
+ import { CoreFileController, pipeFileToResponse } from './core-file.controller';
8
+
9
+ /**
10
+ * Build a response stub that records what the handler did to it.
11
+ *
12
+ * A real `Response` is not needed here — what is under test is the decision
13
+ * (status vs. destroy), not Express. Writing it out as a stub also makes the
14
+ * "headers already sent" case reachable, which is awkward to force otherwise.
15
+ *
16
+ * What this stub CANNOT show is how Express itself treats headers: `res.json()`
17
+ * only defaults `Content-Type` when none is set, so a stub whose `json()` merely
18
+ * records a body can never reveal a stale one. That property is pinned at the
19
+ * HTTP level in `tests/file.e2e-spec.ts`; here we assert the removal list.
20
+ */
21
+ function responseStub(headersSent = false) {
22
+ const sink = new PassThrough();
23
+ const calls = {
24
+ destroyed: false,
25
+ json: undefined as unknown,
26
+ removed: [] as string[],
27
+ set: {} as Record<string, string>,
28
+ status: 0,
29
+ };
30
+
31
+ const res = Object.assign(sink, {
32
+ destroy: () => {
33
+ calls.destroyed = true;
34
+ return res;
35
+ },
36
+ headersSent,
37
+ json: (body: unknown) => {
38
+ calls.json = body;
39
+ return res;
40
+ },
41
+ removeHeader: (name: string) => {
42
+ calls.removed.push(name);
43
+ },
44
+ setHeader: (name: string, value: string) => {
45
+ calls.set[name] = value;
46
+ return res;
47
+ },
48
+ status: (code: number) => {
49
+ calls.status = code;
50
+ return res;
51
+ },
52
+ }) as unknown as Response;
53
+
54
+ return { calls, res };
55
+ }
56
+
57
+ describe('pipeFileToResponse', () => {
58
+ it('turns a read error into 404 while nothing has been written yet', async () => {
59
+ // A GridFS record and its bytes are two separate writes, so the record can
60
+ // outlive the chunks. GridFS reports that on the stream, asynchronously.
61
+ // A bare `stream.pipe(res)` installs no error handler, so the error goes
62
+ // unhandled, Node destroys the socket mid-response, and any reverse proxy
63
+ // in front reports 502 Bad Gateway — "the server is down", which is the one
64
+ // diagnosis that is wrong while every other route answers normally.
65
+ const stream = new Readable({ read() {} });
66
+ const { calls, res } = responseStub(false);
67
+
68
+ pipeFileToResponse(stream, res);
69
+ stream.emit('error', new Error('FileNotFound: file 0123456789ab was not found'));
70
+ await new Promise((resolve) => setImmediate(resolve));
71
+
72
+ expect(calls.status).toBe(404);
73
+ expect(calls.json).toMatchObject({ statusCode: 404 });
74
+ expect(calls.destroyed).toBe(false);
75
+ });
76
+
77
+ it('drops every header that describes the file, not just the download one', async () => {
78
+ // Each of these would otherwise survive onto the error response:
79
+ // `Content-Disposition` offers a JSON error body as a file to save, a long
80
+ // `Cache-Control` lets the client cache the failure, `ETag` describes bytes
81
+ // that were never sent — and `Content-Type` makes an ofetch/`$fetch` client
82
+ // parse the JSON as an image and lose the message entirely.
83
+ const stream = new Readable({ read() {} });
84
+ const { calls, res } = responseStub(false);
85
+
86
+ pipeFileToResponse(stream, res);
87
+ stream.emit('error', new Error('chunks missing'));
88
+ await new Promise((resolve) => setImmediate(resolve));
89
+
90
+ expect(calls.removed).toEqual(
91
+ expect.arrayContaining(['Cache-Control', 'Content-Disposition', 'Content-Type', 'ETag']),
92
+ );
93
+ expect(calls.set['X-Content-Type-Options']).toBe('nosniff');
94
+ });
95
+
96
+ it('closes the connection once bytes are already on the wire', async () => {
97
+ // Past the first byte there is no status left to send. Dropping the
98
+ // connection is the only remaining signal — and it is the correct one: it
99
+ // is what a truncated transfer looks like, so the client does not cache a
100
+ // half file as if it were complete.
101
+ const stream = new Readable({ read() {} });
102
+ const { calls, res } = responseStub(true);
103
+
104
+ pipeFileToResponse(stream, res);
105
+ stream.emit('error', new Error('connection lost mid-stream'));
106
+ await new Promise((resolve) => setImmediate(resolve));
107
+
108
+ expect(calls.destroyed).toBe(true);
109
+ expect(calls.status).toBe(0);
110
+ });
111
+
112
+ it('answers a refused download exactly like an unknown id', async () => {
113
+ // `getFileStream()` returns null when the service's own `checkRights()`
114
+ // refuses — a branch only a CONSUMER project can reach, since the framework's
115
+ // base implementation always returns true. That is precisely why the
116
+ // framework has to pin it: consumers cannot, and if the two answers ever
117
+ // diverge (403 here, 404 there, or different messages) the endpoint becomes
118
+ // an existence oracle for files the caller may not read.
119
+ // Invoked through the prototype rather than a subclass: `CoreFileController`
120
+ // has a protected constructor (it is abstract by design), and a subclass added
121
+ // only to widen that visibility is exactly what `no-useless-constructor`
122
+ // strips — leaving code that no longer compiles.
123
+ const download = (service: Partial<CoreFileService>, res: Response) =>
124
+ (CoreFileController.prototype.getFileById as (this: unknown, id: string, res: Response) => Promise<unknown>).call(
125
+ { fileService: service },
126
+ 'abc',
127
+ res,
128
+ );
129
+
130
+ const file = { contentType: 'image/png', filename: 'secret.png', id: 'abc' };
131
+ const { res } = responseStub(false);
132
+
133
+ const refusedErr = await download(
134
+ { getFileInfo: async () => file, getFileStream: async () => null } as unknown as Partial<CoreFileService>,
135
+ res,
136
+ ).catch((e: unknown) => e);
137
+ const unknownErr = await download(
138
+ { getFileInfo: async () => null, getFileStream: async () => null } as unknown as Partial<CoreFileService>,
139
+ res,
140
+ ).catch((e: unknown) => e);
141
+
142
+ expect(refusedErr).toBeInstanceOf(NotFoundException);
143
+ expect(unknownErr).toBeInstanceOf(NotFoundException);
144
+ // Byte-identical answers: same status, same message.
145
+ expect((refusedErr as NotFoundException).getStatus()).toBe((unknownErr as NotFoundException).getStatus());
146
+ expect((refusedErr as NotFoundException).getResponse()).toEqual((unknownErr as NotFoundException).getResponse());
147
+ });
148
+
149
+ it('destroys the source stream when the client goes away', async () => {
150
+ // `pipe()` only ever unpipes the DESTINATION; it never destroys the source.
151
+ // On a public download route an aborted request would therefore leak the
152
+ // GridFS read stream and its server-side cursor until the cursor timeout.
153
+ const stream = new Readable({ read() {} });
154
+ const { res } = responseStub(false);
155
+
156
+ pipeFileToResponse(stream, res);
157
+ expect(stream.destroyed).toBe(false);
158
+
159
+ res.emit('close');
160
+ await new Promise((resolve) => setImmediate(resolve));
161
+
162
+ expect(stream.destroyed).toBe(true);
163
+ });
164
+ });
@@ -1,10 +1,82 @@
1
- import { BadRequestException, Controller, Get, NotFoundException, Param, Res } from '@nestjs/common';
2
- import { Response } from 'express';
1
+ import { BadRequestException, Controller, Get, Logger, NotFoundException, Param, Res } from '@nestjs/common';
2
+ import type { Response } from 'express';
3
+ import type { Readable } from 'stream';
3
4
 
4
5
  import { Roles } from '../../common/decorators/roles.decorator';
5
6
  import { RoleEnum } from '../../common/enums/role.enum';
7
+ import { ErrorCode } from '../error-code/error-codes';
6
8
  import { CoreFileService } from './core-file.service';
7
9
 
10
+ const fileStreamLogger = new Logger('CoreFileController');
11
+
12
+ /**
13
+ * Headers that describe the FILE and must not survive onto an error response.
14
+ *
15
+ * Deliberately not the whole set: CORS headers are already on the response at
16
+ * this point, and removing those would turn a readable 404 into an opaque CORS
17
+ * failure in the browser — hiding the very answer this handler exists to give.
18
+ *
19
+ * `Content-Type` is the one most easily missed. Express only defaults it in
20
+ * `res.json()` when nothing is set yet (`if (!this.get('Content-Type'))`), so the
21
+ * file's own type would otherwise label a JSON body as `image/png` — and an
22
+ * ofetch/`$fetch` client picks its parser from that header and hands the caller a
23
+ * Blob instead of the error message.
24
+ */
25
+ const FILE_DELIVERY_HEADERS = ['Cache-Control', 'Content-Disposition', 'Content-Type', 'ETag'];
26
+
27
+ /**
28
+ * Pipe a GridFS download to the response without letting a read error kill the socket.
29
+ *
30
+ * A GridFS record and its chunks are two separate writes, so a file document can
31
+ * outlive its bytes — an interrupted upload, a restored backup, a manual cleanup.
32
+ * GridFS reports that asynchronously, on the stream, and `stream.pipe(res)` alone
33
+ * installs no error handler: the error goes unhandled, Node destroys the socket
34
+ * mid-response, and any reverse proxy in front turns that into **502 Bad Gateway**.
35
+ * That reads as "the server is down" — the one diagnosis that is wrong here, while
36
+ * every other route keeps answering normally.
37
+ *
38
+ * Nothing has been written when GridFS reports a missing file, so the status is
39
+ * still ours to set and the answer becomes an honest 404. Once bytes are on the
40
+ * wire there is no status left to send and closing the connection is all that
41
+ * remains — but at that point it genuinely is a truncated transfer, which is
42
+ * exactly what a dropped connection means to the client.
43
+ *
44
+ * Note on `pipe()` vs `stream.pipeline()`: pipeline would destroy BOTH streams on
45
+ * error, including the response — which is precisely the object still needed to
46
+ * send the 404. So the source is cleaned up explicitly on `res` close instead;
47
+ * `pipe()` only ever unpipes the destination and would otherwise leave the
48
+ * GridFS read stream and its server-side cursor open on every aborted download.
49
+ */
50
+ export function pipeFileToResponse(stream: Readable, res: Response): Response {
51
+ res.on('close', () => {
52
+ if (!stream.destroyed) {
53
+ stream.destroy();
54
+ }
55
+ });
56
+
57
+ stream.on('error', (err: Error) => {
58
+ // The answer to the CLIENT stays deliberately indistinguishable from an
59
+ // unknown id, but the server must not lose the event: a files document
60
+ // without its chunks is data corruption, and an error silently converted
61
+ // into a 404 is one nobody will ever notice.
62
+ fileStreamLogger.error(`GridFS download failed: ${err.message}`, err.stack);
63
+
64
+ if (res.headersSent) {
65
+ res.destroy();
66
+ return;
67
+ }
68
+ for (const header of FILE_DELIVERY_HEADERS) {
69
+ res.removeHeader(header);
70
+ }
71
+ // No global nosniff on this route, and the body is now correctly typed —
72
+ // set it anyway so a mislabelled response can never be sniffed into markup.
73
+ res.setHeader('X-Content-Type-Options', 'nosniff');
74
+ res.status(404).json({ error: 'Not Found', message: ErrorCode.FILE_NOT_FOUND, statusCode: 404 });
75
+ });
76
+
77
+ return stream.pipe(res);
78
+ }
79
+
8
80
  /**
9
81
  * File controller
10
82
  */
@@ -16,6 +88,18 @@ export abstract class CoreFileController {
16
88
  */
17
89
  protected constructor(protected fileService: CoreFileService) {}
18
90
 
91
+ /**
92
+ * Stream a file to the response.
93
+ *
94
+ * Delegates to the module-level {@link pipeFileToResponse} (which stays exported
95
+ * so it can be unit-tested without a Nest context). Override this to change the
96
+ * error status, the body shape or the logging for a project — the free function
97
+ * alone would not be reachable from a subclass.
98
+ */
99
+ protected pipeFileToResponse(stream: Readable, res: Response): Response {
100
+ return pipeFileToResponse(stream, res);
101
+ }
102
+
19
103
  /**
20
104
  * Download file by ID
21
105
  *
@@ -26,17 +110,22 @@ export abstract class CoreFileController {
26
110
  @Roles(RoleEnum.S_EVERYONE)
27
111
  async getFileById(@Param('id') id: string, @Res() res: Response) {
28
112
  if (!id) {
29
- throw new BadRequestException('Missing file ID for download');
113
+ throw new BadRequestException(ErrorCode.REQUIRED_FIELD_MISSING);
30
114
  }
31
115
 
32
116
  const file = await this.fileService.getFileInfo(id);
33
117
  if (!file) {
34
- throw new NotFoundException('File not found');
118
+ throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
35
119
  }
36
120
  const filestream = await this.fileService.getFileStream(id);
121
+ // `getFileStream` answers null when the service's own rights check refuses.
122
+ // Same answer as an unknown id: never confirm that the file exists.
123
+ if (!filestream) {
124
+ throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
125
+ }
37
126
  res.header('Content-Type', file.contentType || 'application/octet-stream');
38
127
  res.header('Content-Disposition', `attachment; filename=${file.filename}`);
39
- return filestream.pipe(res);
128
+ return this.pipeFileToResponse(filestream, res);
40
129
  }
41
130
 
42
131
  /**
@@ -49,16 +138,19 @@ export abstract class CoreFileController {
49
138
  @Roles(RoleEnum.S_EVERYONE)
50
139
  async getFile(@Param('filename') filename: string, @Res() res: Response) {
51
140
  if (!filename) {
52
- throw new BadRequestException('Missing filename for download');
141
+ throw new BadRequestException(ErrorCode.REQUIRED_FIELD_MISSING);
53
142
  }
54
143
 
55
144
  const file = await this.fileService.getFileInfoByName(filename);
56
145
  if (!file) {
57
- throw new NotFoundException('File not found');
146
+ throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
58
147
  }
59
148
  const filestream = await this.fileService.getFileStream(file.id);
149
+ if (!filestream) {
150
+ throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
151
+ }
60
152
  res.header('Content-Type', file.contentType || 'application/octet-stream');
61
153
  res.header('Content-Disposition', `attachment; filename=${file.filename}`);
62
- return filestream.pipe(res);
154
+ return this.pipeFileToResponse(filestream, res);
63
155
  }
64
156
  }
@@ -387,6 +387,41 @@ const fileId = await uploadFileToGridFS('mongodb://localhost/mydb', '../assets/i
387
387
  });
388
388
  ```
389
389
 
390
+ `relativePath` is resolved against the **helper module's** directory, not the migration file's — the
391
+ paths in this example and in the migration template are written accordingly.
392
+
393
+ The returned promise settles only after three guarantees hold:
394
+
395
+ | Guarantee | Behaviour |
396
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
397
+ | The bytes are stored | Chunk completeness is verified before resolving (see `assertGridFsFileComplete()` below). An incomplete upload **rejects** and the incomplete file is removed, rather than returning an id that points at nothing |
398
+ | An unreadable source fails fast | A missing or unreadable file **rejects** with the underlying `ENOENT`. It used to hang forever, because `pipe()` does not forward read-stream errors |
399
+ | The connection is released | The MongoClient it opens is always closed, on the success and the failure path alike. A leaked connection keeps the Node event loop alive and `migrate up` never exits |
400
+
401
+ Since these are hard failures, a seed migration whose asset is missing or only partially stored now
402
+ fails the migration — and with the default `MIGRATE_FAILURE_POLICY=abort` in `docker-entrypoint.sh`,
403
+ the container refuses to start rather than booting with broken data. That is intentional: the point
404
+ of running migrations before the server is to find exactly this.
405
+
406
+ ### assertGridFsFileComplete()
407
+
408
+ Verifies that every chunk of an already-stored GridFS file is present. `uploadFileToGridFS()` calls
409
+ it for you; call it directly to check files written by something else (a restored dump, another
410
+ service, a manual upload):
411
+
412
+ ```typescript
413
+ import { assertGridFsFileComplete, getDb } from '@lenne.tech/nest-server';
414
+
415
+ const db = await getDb('mongodb://localhost/mydb');
416
+ await assertGridFsFileComplete(db, 'images', fileId, 'logo.png'); // throws if incomplete
417
+ ```
418
+
419
+ Throws when the files document is missing entirely, or when fewer chunks are stored than its
420
+ `length` implies. It counts chunk documents rather than reading bytes back, so a chunk that was
421
+ written but truncated is **not** detected — the check targets the failure that actually occurs, a
422
+ connection lost mid-upload. Empty files are valid and pass: GridFS stores a zero-byte file with no
423
+ chunk documents at all.
424
+
390
425
  ### Migration Templates
391
426
 
392
427
  Ready-to-use template for nest-server projects:
@@ -328,13 +328,76 @@ Environment Variables:
328
328
  `);
329
329
  }
330
330
 
331
- // Run CLI if executed directly
332
- if (require.main === module) {
333
- main().catch((error) => {
331
+ /**
332
+ * Exit with `code`, but never before our own output has actually left the process.
333
+ *
334
+ * `process.exit()` does NOT drain stdout, and stdout is asynchronous whenever it
335
+ * is a pipe — which is exactly the Docker / CI / `| tee` case. Exiting straight
336
+ * after the completion log therefore discards the record of what this run did,
337
+ * and the line at risk is the last one ("All migrations completed successfully"),
338
+ * which is precisely what a CI step greps for. Measured: past the 64 KiB pipe
339
+ * buffer everything beyond it is lost.
340
+ *
341
+ * `process.exitCode` is assigned first so that a future `exitCode = 1` elsewhere
342
+ * is honoured rather than overwritten by a hardcoded 0. The timer is the safety
343
+ * net for the opposite failure — a consumer that never reads — and is `unref`'d
344
+ * so it cannot itself keep the process alive.
345
+ */
346
+ const flushAndExit = async (code: number): Promise<void> => {
347
+ process.exitCode = code;
348
+
349
+ const guard = setTimeout(() => process.exit(code), 5000);
350
+ guard.unref();
351
+
352
+ await Promise.all(
353
+ [process.stdout, process.stderr].map(
354
+ (stream) =>
355
+ new Promise<void>((resolve) => {
356
+ if (!stream.writableLength) {
357
+ resolve();
358
+ return;
359
+ }
360
+ stream.write('', () => resolve());
361
+ }),
362
+ ),
363
+ );
364
+
365
+ clearTimeout(guard);
366
+ process.exit(code);
367
+ };
368
+
369
+ /**
370
+ * Run the CLI and terminate the process when it is done.
371
+ *
372
+ * This is the entry point the `migrate` / `nest-migrate` bin uses. It exists
373
+ * separately from `main()` because the shell around it matters: migrations touch
374
+ * MongoDB, GridFS and — via the state store — a second connection, and any handle
375
+ * one of them leaves behind keeps Node alive forever. The CLI then prints
376
+ * "All migrations completed successfully" and simply never returns: a CI job
377
+ * blocks until its timeout, and a container entrypoint that runs migrations
378
+ * before `exec`ing the server never reaches the server at all.
379
+ *
380
+ * Note this must NOT be guarded by `require.main === module`: the shipped
381
+ * `bin/migrate.js` loads this module and calls in, so `require.main` is the shim,
382
+ * never this file. A guard here would make the exit unreachable on every path
383
+ * that actually ships.
384
+ */
385
+ const runCli = async (): Promise<void> => {
386
+ try {
387
+ await main();
388
+ } catch (error) {
334
389
  console.error('Fatal error:', error);
335
- process.exit(1);
336
- });
390
+ await flushAndExit(1);
391
+ return;
392
+ }
393
+ await flushAndExit(0);
394
+ };
395
+
396
+ // Run CLI if executed directly (`node migrate-cli.js`). The bin shim calls
397
+ // runCli() itself, so this only covers a direct invocation of the compiled file.
398
+ if (require.main === module) {
399
+ void runCli();
337
400
  }
338
401
 
339
402
  // parseArgs is exported for unit testing only (same pattern as resolveCliPath in bin/migrate.js)
340
- export { main, parseArgs };
403
+ export { flushAndExit, main, parseArgs, runCli };
@@ -0,0 +1,85 @@
1
+ import type { Db } from 'mongodb';
2
+ import { ObjectId } from 'mongodb';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { assertGridFsFileComplete } from './migration.helper';
6
+
7
+ /**
8
+ * Minimal stand-in for the two collections the check reads.
9
+ *
10
+ * A real Mongo instance is not needed to pin this contract — what matters is
11
+ * the decision the function makes given a files document and a chunk count.
12
+ * The stub records what it was asked for, so a hardcoded bucket name or a wrong
13
+ * filter key cannot slip past unnoticed; the real-MongoDB counterpart lives in
14
+ * `tests/migrate/upload-file-to-gridfs.e2e-spec.ts`.
15
+ */
16
+ function dbStub(files: null | Record<string, unknown>, chunkCount: number) {
17
+ const asked: { filters: unknown[]; names: string[] } = { filters: [], names: [] };
18
+
19
+ const db = {
20
+ collection: (name: string) => {
21
+ asked.names.push(name);
22
+ if (name.endsWith('.files')) {
23
+ return {
24
+ findOne: async (filter: unknown) => {
25
+ asked.filters.push(filter);
26
+ return files;
27
+ },
28
+ };
29
+ }
30
+ return {
31
+ countDocuments: async (filter: unknown) => {
32
+ asked.filters.push(filter);
33
+ return chunkCount;
34
+ },
35
+ };
36
+ },
37
+ } as unknown as Db;
38
+
39
+ return { asked, db };
40
+ }
41
+
42
+ describe('assertGridFsFileComplete', () => {
43
+ const id = new ObjectId();
44
+
45
+ it('passes when every chunk is stored', async () => {
46
+ // 700 KiB at the default 255 KiB chunk size = 3 chunks.
47
+ const { db } = dbStub({ _id: id, chunkSize: 261120, length: 716800 }, 3);
48
+ await expect(assertGridFsFileComplete(db, 'fs', id, 'layout.jpg')).resolves.toBeUndefined();
49
+ });
50
+
51
+ it('rejects when the record promises more bytes than exist', async () => {
52
+ // The nasty case: the files document looks perfectly healthy, so every
53
+ // listing and every metadata check says the file is fine — only the
54
+ // download fails. Reporting this at upload time is what keeps a broken
55
+ // asset from being persisted as if it were good.
56
+ const { db } = dbStub({ _id: id, chunkSize: 261120, length: 716800 }, 1);
57
+ await expect(assertGridFsFileComplete(db, 'fs', id, 'layout.jpg')).rejects.toThrow(/incomplete: 1 of 3 chunks/);
58
+ });
59
+
60
+ it('rejects when the file document is missing entirely', async () => {
61
+ const { db } = dbStub(null, 0);
62
+ await expect(assertGridFsFileComplete(db, 'fs', id, 'layout.jpg')).rejects.toThrow(/no file document/);
63
+ });
64
+
65
+ it('accepts an empty file, which GridFS stores with no chunks at all', async () => {
66
+ // GridFS does NOT write a placeholder chunk for a zero-byte file: the
67
+ // driver's `writeRemnant()` returns early on `pos === 0`. A `Math.max(1, …)`
68
+ // floor in the expectation would therefore reject every legitimately empty
69
+ // asset — and since the container entrypoint defaults to
70
+ // `MIGRATE_FAILURE_POLICY=abort`, that would keep the server from starting.
71
+ const { db } = dbStub({ _id: id, chunkSize: 261120, length: 0 }, 0);
72
+ await expect(assertGridFsFileComplete(db, 'fs', id, 'empty.bin')).resolves.toBeUndefined();
73
+ });
74
+
75
+ it('reads the bucket it was given, filtered by the file id', async () => {
76
+ // Guards the two things the assertions above cannot see: that the bucket
77
+ // name is not hardcoded, and that the chunk lookup uses `files_id` (a wrong
78
+ // key would count zero and turn every upload into a false failure).
79
+ const { asked, db } = dbStub({ _id: id, chunkSize: 261120, length: 261120 }, 1);
80
+ await assertGridFsFileComplete(db, 'images', id, 'logo.png');
81
+
82
+ expect(asked.names).toEqual(['images.files', 'images.chunks']);
83
+ expect(asked.filters).toEqual([{ _id: id }, { files_id: id }]);
84
+ });
85
+ });