Skip to content

Commit fa125a2

Browse files
committed
fix(document): address PR review feedback on thumbnail generation
- Add setCredentialsForTest() to FileService for proper test injection - Rewrite thumbnail integration tests to call real create/delete paths - Use real toFile converter in property tests instead of local copy - Add comment documenting orphaned-blob edge case in ThumbnailService - Add no-console to eslint-disable in backfill script
1 parent 8be4a7b commit fa125a2

7 files changed

Lines changed: 84 additions & 171 deletions

File tree

api/services/FileService.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,26 @@ module.exports = {
8686

8787
isCredentials: !!credentials,
8888

89+
/**
90+
* Test-only: Retrieve the current module-level credentials object.
91+
* Used by tests to save original credentials before injecting mocks.
92+
* @returns {object|null}
93+
*/
94+
getCredentialsForTest() {
95+
return credentials;
96+
},
97+
98+
/**
99+
* Test-only: Override the module-level credentials object.
100+
* Allows integration tests to inject a mock blob client without
101+
* reimplementing create/delete. Call with `null` to restore original.
102+
* @param {object|null} mockCredentials
103+
*/
104+
setCredentialsForTest(mockCredentials) {
105+
credentials = mockCredentials;
106+
this.isCredentials = !!mockCredentials;
107+
},
108+
89109
document: {
90110
getUrl(path) {
91111
// The documents container allow anonymous access

api/services/ThumbnailService.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,15 @@ module.exports = {
8484
/**
8585
* Generate all applicable thumbnail variants for an image.
8686
* Uses an all-or-nothing approach: generates all buffers first, then uploads.
87-
* If any step fails, returns all nulls.
87+
* If any step fails, returns all nulls with hadError: true.
8888
*
8989
* @param {Buffer} imageBuffer - The original image file buffer
9090
* @param {string} originalPath - The blob path of the original
9191
* @param {object} blobClient - Azure container client for uploading
92-
* @returns {Promise<{small: string|null, medium: string|null, large: string|null}>}
92+
* @returns {Promise<{small: string|null, medium: string|null, large: string|null, hadError: boolean}>}
9393
*/
9494
async generate(imageBuffer, originalPath, blobClient) {
95-
const result = { small: null, medium: null, large: null };
95+
const result = { small: null, medium: null, large: null, hadError: false };
9696

9797
try {
9898
const metadata = await sharp(imageBuffer).metadata();
@@ -118,6 +118,12 @@ module.exports = {
118118
);
119119

120120
// Upload all variants
121+
// NOTE: If a subset of uploads succeeds before one fails, the successful
122+
// uploads become orphaned blobs in Azure (the catch block returns all-null,
123+
// so the DB never stores those paths and FileService.document.delete won't
124+
// clean them up). This is a known trade-off: orphaned blobs are rare and
125+
// low-cost compared to the complexity of per-variant rollback. A periodic
126+
// storage cleanup job could address this if it becomes a concern.
121127
await Promise.all(
122128
buffers.map(async ({ path: blobPath, buffer }) => {
123129
const blockBlobClient = blobClient.getBlockBlobClient(blobPath);
@@ -133,7 +139,7 @@ module.exports = {
133139
});
134140
} catch (err) {
135141
sails.log.error('ThumbnailService.generate failed:', err);
136-
return { small: null, medium: null, large: null };
142+
return { small: null, medium: null, large: null, hadError: true };
137143
}
138144

139145
return result;

scripts/backfill-thumbnails.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22

3-
/* eslint-disable no-await-in-loop, no-loop-func, global-require */
3+
/* eslint-disable no-await-in-loop, no-loop-func, global-require, no-console */
44

55
/**
66
* Backfill Thumbnails Script
@@ -163,6 +163,11 @@ async function run() {
163163
thumbnailLarge: thumbnailPaths.large,
164164
});
165165
succeeded += 1;
166+
} else if (thumbnailPaths.hadError) {
167+
console.error(
168+
`WARNING: file ${file.id} failed thumbnail generation (not just too small)`
169+
);
170+
failed += 1;
166171
} else {
167172
skipped += 1;
168173
}

test/integration/1_services/FileService.thumbnails.test.js

Lines changed: 24 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -2,155 +2,55 @@ const should = require('should');
22
const sinon = require('sinon');
33
const sharp = require('sharp');
44
const FileService = require('../../../api/services/FileService');
5-
const ThumbnailService = require('../../../api/services/ThumbnailService');
65

76
describe('FileService.document - Thumbnail Integration', () => {
8-
// Store original methods for restoration
9-
const originalCreate = FileService.document.create;
10-
const originalDelete = FileService.document.delete;
11-
const originalIsCredentials = FileService.isCredentials;
12-
13-
// Mock blob client
147
let mockUploadData;
158
let mockDeleteBlob;
16-
let uploadedBlobs;
179
let mockContainerClient;
10+
let originalIsCredentials;
11+
let originalCredentials;
1812

1913
before(() => {
14+
// Save original state to restore later
15+
originalIsCredentials = FileService.isCredentials;
16+
originalCredentials = FileService.getCredentialsForTest();
17+
2018
mockUploadData = sinon.stub().resolves();
2119
mockDeleteBlob = sinon.stub().resolves();
22-
uploadedBlobs = {};
2320

2421
mockContainerClient = {
2522
getBlockBlobClient: (blobPath) => ({
26-
uploadData: async (buffer, options) => {
27-
uploadedBlobs[blobPath] = { buffer, options };
28-
return mockUploadData(buffer, options);
29-
},
30-
delete: async (options) => {
31-
mockDeleteBlob(blobPath, options);
32-
},
23+
uploadData: async (buffer, options) =>
24+
mockUploadData(blobPath, buffer, options),
25+
delete: async (options) => mockDeleteBlob(blobPath, options),
3326
}),
3427
};
3528

36-
// Patch create to use mock credentials
37-
FileService.document.create = async function (
38-
file,
39-
idDocument,
40-
fetchResult = false,
41-
isValidated = true
42-
) {
43-
const name = file.originalname;
44-
const pathName = `${Math.random()
45-
.toString()
46-
.replace(/0\./, '')}-${name.replace(/ /, '_')}`;
47-
const lastDot = name.lastIndexOf('.');
48-
if (lastDot <= 0 || lastDot === name.length - 1) {
49-
const err = new Error(FileService.INVALID_NAME);
50-
err.fileName = name;
51-
throw err;
52-
}
53-
const extension = name.slice(lastDot + 1).toLowerCase();
54-
55-
const foundFormat = await TFileFormat.find({ extension }).limit(1);
56-
if (foundFormat.length === 0) {
57-
const err = new Error(FileService.INVALID_FORMAT);
58-
err.fileName = name;
59-
throw err;
60-
}
61-
62-
// Upload original
63-
const blockBlobClient = mockContainerClient.getBlockBlobClient(pathName);
64-
await blockBlobClient.uploadData(file.buffer, {
65-
blobHTTPHeaders: { blobContentType: foundFormat[0].mimeType },
66-
});
67-
68-
// Thumbnail generation (matches actual FileService logic)
69-
let thumbnailPaths = { small: null, medium: null, large: null };
70-
const fileMimeType = foundFormat[0].mimeType;
71-
if (ThumbnailService.isProcessable(fileMimeType)) {
72-
try {
73-
thumbnailPaths = await ThumbnailService.generate(
74-
file.buffer,
75-
pathName,
76-
mockContainerClient
77-
);
78-
} catch (err) {
79-
sails.log.error('Thumbnail generation failed:', err);
80-
}
81-
}
82-
83-
const param = {
84-
dateInscription: new Date(),
85-
fileName: name,
86-
document: idDocument,
87-
fileFormat: foundFormat[0].id,
88-
path: pathName,
89-
isValidated,
90-
thumbnailSmall: thumbnailPaths.small,
91-
thumbnailMedium: thumbnailPaths.medium,
92-
thumbnailLarge: thumbnailPaths.large,
93-
};
94-
if (fetchResult) {
95-
return TFile.create(param).fetch();
96-
}
97-
return TFile.create(param);
98-
};
99-
100-
// Patch delete to use mock credentials
101-
FileService.document.delete = async function (file) {
102-
const destroyedRecord = await TFile.destroyOne(file.id);
103-
const blockBlobClient = mockContainerClient.getBlockBlobClient(
104-
destroyedRecord.path
105-
);
106-
await blockBlobClient.delete({ deleteSnapshots: 'include' });
107-
108-
// Clean up thumbnail blobs
109-
const thumbnailPaths = [
110-
destroyedRecord.thumbnailSmall,
111-
destroyedRecord.thumbnailMedium,
112-
destroyedRecord.thumbnailLarge,
113-
].filter(Boolean);
114-
await Promise.all(
115-
thumbnailPaths.map(async (thumbPath) => {
116-
try {
117-
const thumbBlobClient =
118-
mockContainerClient.getBlockBlobClient(thumbPath);
119-
await thumbBlobClient.delete({ deleteSnapshots: 'include' });
120-
} catch (err) {
121-
sails.log.error(
122-
`Failed to delete thumbnail blob ${thumbPath}:`,
123-
err
124-
);
125-
}
126-
})
127-
);
128-
129-
return destroyedRecord;
130-
};
131-
132-
FileService.isCredentials = true;
29+
// Inject mock credentials so the real create/delete code paths run
30+
FileService.setCredentialsForTest({
31+
documentsBlobClient: mockContainerClient,
32+
});
13333
});
13434

13535
beforeEach(() => {
13636
mockUploadData.resetHistory();
13737
mockDeleteBlob.resetHistory();
138-
uploadedBlobs = {};
13938
});
14039

14140
after(() => {
142-
FileService.document.create = originalCreate;
143-
FileService.document.delete = originalDelete;
41+
// Restore original credentials state
42+
FileService.setCredentialsForTest(originalCredentials);
14443
FileService.isCredentials = originalIsCredentials;
14544
sinon.restore();
14645
});
14746

14847
describe('create() with image file', () => {
14948
let createdFile;
15049

151-
after(async () => {
50+
afterEach(async () => {
15251
if (createdFile) {
15352
await TFile.destroyOne(createdFile.id);
53+
createdFile = null;
15454
}
15555
});
15656

@@ -183,8 +83,7 @@ describe('FileService.document - Thumbnail Integration', () => {
18383
should(createdFile.thumbnailMedium).startWith('thumbnails/medium/');
18484
should(createdFile.thumbnailMedium).endWith('.webp');
18585

186-
// Large should be null — original is only 2000px, not > 1920
187-
// Actually 2000 > 1920, so large IS generated
86+
// 2000 > 1920, so large IS generated
18887
should(createdFile.thumbnailLarge).be.a.String();
18988
should(createdFile.thumbnailLarge).startWith('thumbnails/large/');
19089
should(createdFile.thumbnailLarge).endWith('.webp');
@@ -208,13 +107,11 @@ describe('FileService.document - Thumbnail Integration', () => {
208107
size: imageBuffer.length,
209108
};
210109

211-
const result = await FileService.document.create(file, 1, true);
212-
213-
should(result.thumbnailSmall).be.a.String();
214-
should(result.thumbnailMedium).be.a.String();
215-
should(result.thumbnailLarge).be.null();
110+
createdFile = await FileService.document.create(file, 1, true);
216111

217-
await TFile.destroyOne(result.id);
112+
should(createdFile.thumbnailSmall).be.a.String();
113+
should(createdFile.thumbnailMedium).be.a.String();
114+
should(createdFile.thumbnailLarge).be.null();
218115
});
219116
});
220117

@@ -254,9 +151,9 @@ describe('FileService.document - Thumbnail Integration', () => {
254151

255152
describe('create() with thumbnail generation failure', () => {
256153
it('should still succeed with null thumbnails when sharp fails', async () => {
257-
// Stub ThumbnailService.generate to throw
154+
// Stub the global ThumbnailService.generate (Sails auto-globalized)
258155
const generateStub = sinon
259-
.stub(ThumbnailService, 'generate')
156+
.stub(global.ThumbnailService, 'generate')
260157
.rejects(new Error('sharp exploded'));
261158
const logStub = sinon.stub(sails.log, 'error');
262159

test/integration/1_services/GeoLocNetworks.property.test.js

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,7 @@ const networkRowsArbitrary = fc
5757

5858
describe('GeoLocService formatNetworks - Property Tests', () => {
5959
describe('Property 1: every network has more than one entrance', () => {
60-
it('should produce networks each with at least 2 entrances', function () {
61-
this.timeout(10000);
60+
it('should produce networks each with at least 2 entrances', () => {
6261
fc.assert(
6362
fc.property(networkRowsArbitrary, (rows) => {
6463
const networks = formatNetworks(rows);
@@ -68,12 +67,11 @@ describe('GeoLocService formatNetworks - Property Tests', () => {
6867
}),
6968
{ numRuns: 100 }
7069
);
71-
});
70+
}).timeout(10000);
7271
});
7372

7473
describe('Property 2: centroid equals arithmetic mean of entrance coordinates', () => {
75-
it('should have centroid matching average of entrance lat/lng', function () {
76-
this.timeout(10000);
74+
it('should have centroid matching average of entrance lat/lng', () => {
7775
fc.assert(
7876
fc.property(networkRowsArbitrary, (rows) => {
7977
const networks = formatNetworks(rows);
@@ -90,12 +88,11 @@ describe('GeoLocService formatNetworks - Property Tests', () => {
9088
}),
9189
{ numRuns: 100 }
9290
);
93-
});
91+
}).timeout(10000);
9492
});
9593

9694
describe('Property 6: backwards-compatible output shape', () => {
97-
it('should always include id, name, longitude, latitude, entrances', function () {
98-
this.timeout(10000);
95+
it('should always include id, name, longitude, latitude, entrances', () => {
9996
fc.assert(
10097
fc.property(networkRowsArbitrary, (rows) => {
10198
const networks = formatNetworks(rows);
@@ -114,6 +111,6 @@ describe('GeoLocService formatNetworks - Property Tests', () => {
114111
}),
115112
{ numRuns: 100 }
116113
);
117-
});
114+
}).timeout(10000);
118115
});
119116
});

0 commit comments

Comments
 (0)