@protontech/drive-sdk 0.9.8 → 0.10.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/dist/crypto/driveCrypto.d.ts +15 -15
- package/dist/crypto/driveCrypto.js.map +1 -1
- package/dist/crypto/hmac.d.ts +3 -3
- package/dist/crypto/hmac.js.map +1 -1
- package/dist/crypto/interface.d.ts +45 -25
- package/dist/crypto/interface.js.map +1 -1
- package/dist/crypto/openPGPCrypto.d.ts +37 -37
- package/dist/crypto/openPGPCrypto.js.map +1 -1
- package/dist/crypto/utils.d.ts +1 -1
- package/dist/interface/index.d.ts +3 -3
- package/dist/interface/index.js.map +1 -1
- package/dist/interface/nodes.d.ts +8 -0
- package/dist/interface/photos.d.ts +18 -1
- package/dist/interface/sharing.d.ts +2 -0
- package/dist/interface/telemetry.d.ts +1 -0
- package/dist/interface/telemetry.js.map +1 -1
- package/dist/interface/thumbnail.d.ts +2 -2
- package/dist/internal/apiService/apiService.js +25 -12
- package/dist/internal/apiService/apiService.js.map +1 -1
- package/dist/internal/apiService/apiService.test.js +33 -5
- package/dist/internal/apiService/apiService.test.js.map +1 -1
- package/dist/internal/apiService/driveTypes.d.ts +2942 -3187
- package/dist/internal/apiService/errors.test.js +17 -7
- package/dist/internal/apiService/errors.test.js.map +1 -1
- package/dist/internal/devices/manager.d.ts +1 -0
- package/dist/internal/devices/manager.js +11 -0
- package/dist/internal/devices/manager.js.map +1 -1
- package/dist/internal/download/apiService.d.ts +1 -1
- package/dist/internal/download/cryptoService.d.ts +4 -4
- package/dist/internal/download/cryptoService.js.map +1 -1
- package/dist/internal/download/fileDownloader.js.map +1 -1
- package/dist/internal/download/fileDownloader.test.js.map +1 -1
- package/dist/internal/download/thumbnailDownloader.js.map +1 -1
- package/dist/internal/nodes/cryptoService.d.ts +4 -4
- package/dist/internal/nodes/cryptoService.js +5 -3
- package/dist/internal/nodes/cryptoService.js.map +1 -1
- package/dist/internal/nodes/cryptoService.test.js.map +1 -1
- package/dist/internal/nodes/interface.d.ts +1 -1
- package/dist/internal/nodes/nodesManagement.js +0 -1
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/photos/addToAlbum.d.ts +46 -0
- package/dist/internal/photos/addToAlbum.js +257 -0
- package/dist/internal/photos/addToAlbum.js.map +1 -0
- package/dist/internal/photos/addToAlbum.test.d.ts +1 -0
- package/dist/internal/photos/addToAlbum.test.js +409 -0
- package/dist/internal/photos/addToAlbum.test.js.map +1 -0
- package/dist/internal/photos/albums.d.ts +7 -2
- package/dist/internal/photos/albums.js +24 -1
- package/dist/internal/photos/albums.js.map +1 -1
- package/dist/internal/photos/albums.test.js +26 -1
- package/dist/internal/photos/albums.test.js.map +1 -1
- package/dist/internal/photos/albumsCrypto.d.ts +20 -3
- package/dist/internal/photos/albumsCrypto.js +27 -0
- package/dist/internal/photos/albumsCrypto.js.map +1 -1
- package/dist/internal/photos/apiService.d.ts +20 -0
- package/dist/internal/photos/apiService.js +142 -0
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/apiService.test.d.ts +1 -0
- package/dist/internal/photos/apiService.test.js +199 -0
- package/dist/internal/photos/apiService.test.js.map +1 -0
- package/dist/internal/photos/errors.d.ts +4 -0
- package/dist/internal/photos/errors.js +17 -0
- package/dist/internal/photos/errors.js.map +1 -0
- package/dist/internal/photos/index.d.ts +1 -1
- package/dist/internal/photos/index.js +1 -1
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/interface.d.ts +36 -1
- package/dist/internal/photos/interface.js +14 -0
- package/dist/internal/photos/interface.js.map +1 -1
- package/dist/internal/photos/nodes.js +32 -2
- package/dist/internal/photos/nodes.js.map +1 -1
- package/dist/internal/photos/nodes.test.js +25 -5
- package/dist/internal/photos/nodes.test.js.map +1 -1
- package/dist/internal/photos/timeline.d.ts +2 -5
- package/dist/internal/photos/timeline.js.map +1 -1
- package/dist/internal/photos/upload.d.ts +2 -2
- package/dist/internal/photos/upload.js.map +1 -1
- package/dist/internal/shares/apiService.js +1 -0
- package/dist/internal/shares/apiService.js.map +1 -1
- package/dist/internal/shares/interface.d.ts +1 -0
- package/dist/internal/sharing/apiService.d.ts +8 -1
- package/dist/internal/sharing/apiService.js +23 -1
- package/dist/internal/sharing/apiService.js.map +1 -1
- package/dist/internal/sharing/cryptoService.js +8 -4
- package/dist/internal/sharing/cryptoService.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.d.ts +1 -0
- package/dist/internal/sharing/sharingManagement.js +15 -2
- package/dist/internal/sharing/sharingManagement.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.test.js +30 -5
- package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
- package/dist/internal/sharingPublic/nodes.d.ts +2 -2
- package/dist/internal/sharingPublic/nodes.js.map +1 -1
- package/dist/internal/upload/apiService.d.ts +5 -5
- package/dist/internal/upload/apiService.js.map +1 -1
- package/dist/internal/upload/blockVerifier.d.ts +2 -2
- package/dist/internal/upload/blockVerifier.js.map +1 -1
- package/dist/internal/upload/chunkStreamReader.d.ts +2 -2
- package/dist/internal/upload/chunkStreamReader.js.map +1 -1
- package/dist/internal/upload/chunkStreamReader.test.js.map +1 -1
- package/dist/internal/upload/cryptoService.d.ts +7 -7
- package/dist/internal/upload/cryptoService.js.map +1 -1
- package/dist/internal/upload/interface.d.ts +6 -6
- package/dist/internal/upload/manager.d.ts +1 -1
- package/dist/internal/upload/manager.js.map +1 -1
- package/dist/internal/upload/streamUploader.d.ts +1 -1
- package/dist/internal/utils.d.ts +1 -1
- package/dist/protonDriveClient.d.ts +8 -0
- package/dist/protonDriveClient.js +11 -0
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +42 -7
- package/dist/protonDrivePhotosClient.js +50 -2
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/protonDrivePublicLinkClient.d.ts +9 -0
- package/dist/protonDrivePublicLinkClient.js +12 -0
- package/dist/protonDrivePublicLinkClient.js.map +1 -1
- package/dist/transformers.js +2 -0
- package/dist/transformers.js.map +1 -1
- package/package.json +4 -4
- package/src/crypto/driveCrypto.ts +15 -15
- package/src/crypto/hmac.ts +4 -4
- package/src/crypto/interface.ts +58 -27
- package/src/crypto/openPGPCrypto.ts +26 -26
- package/src/interface/index.ts +10 -2
- package/src/interface/nodes.ts +1 -0
- package/src/interface/photos.ts +19 -1
- package/src/interface/sharing.ts +2 -0
- package/src/interface/telemetry.ts +1 -0
- package/src/interface/thumbnail.ts +2 -2
- package/src/internal/apiService/apiService.test.ts +38 -6
- package/src/internal/apiService/apiService.ts +33 -12
- package/src/internal/apiService/driveTypes.ts +2942 -3187
- package/src/internal/devices/manager.ts +14 -0
- package/src/internal/download/apiService.ts +1 -1
- package/src/internal/download/cryptoService.ts +4 -4
- package/src/internal/download/fileDownloader.test.ts +4 -4
- package/src/internal/download/fileDownloader.ts +6 -6
- package/src/internal/download/thumbnailDownloader.ts +4 -4
- package/src/internal/nodes/cryptoService.test.ts +2 -2
- package/src/internal/nodes/cryptoService.ts +11 -8
- package/src/internal/nodes/interface.ts +1 -1
- package/src/internal/nodes/nodesManagement.ts +0 -1
- package/src/internal/photos/addToAlbum.test.ts +515 -0
- package/src/internal/photos/addToAlbum.ts +341 -0
- package/src/internal/photos/albums.test.ts +46 -22
- package/src/internal/photos/albums.ts +48 -2
- package/src/internal/photos/albumsCrypto.ts +54 -3
- package/src/internal/photos/apiService.test.ts +233 -0
- package/src/internal/photos/apiService.ts +234 -15
- package/src/internal/photos/errors.ts +11 -0
- package/src/internal/photos/index.ts +2 -2
- package/src/internal/photos/interface.ts +40 -1
- package/src/internal/photos/nodes.test.ts +27 -6
- package/src/internal/photos/nodes.ts +34 -2
- package/src/internal/photos/timeline.ts +2 -5
- package/src/internal/photos/upload.ts +2 -2
- package/src/internal/shares/apiService.ts +1 -0
- package/src/internal/shares/interface.ts +1 -0
- package/src/internal/sharing/apiService.ts +49 -5
- package/src/internal/sharing/cryptoService.ts +10 -4
- package/src/internal/sharing/sharingManagement.test.ts +33 -5
- package/src/internal/sharing/sharingManagement.ts +28 -6
- package/src/internal/sharingPublic/nodes.ts +1 -1
- package/src/internal/upload/apiService.ts +5 -5
- package/src/internal/upload/blockVerifier.ts +3 -3
- package/src/internal/upload/chunkStreamReader.test.ts +7 -7
- package/src/internal/upload/chunkStreamReader.ts +3 -3
- package/src/internal/upload/cryptoService.ts +9 -9
- package/src/internal/upload/interface.ts +6 -6
- package/src/internal/upload/manager.ts +2 -2
- package/src/internal/upload/streamUploader.ts +1 -1
- package/src/protonDriveClient.ts +15 -3
- package/src/protonDrivePhotosClient.ts +78 -22
- package/src/protonDrivePublicLinkClient.ts +13 -0
- package/src/transformers.ts +2 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { NodeResultWithError } from '../../interface';
|
|
2
|
+
import { getMockLogger } from '../../tests/logger';
|
|
3
|
+
import { AddToAlbumProcess } from './addToAlbum';
|
|
4
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
5
|
+
import { PhotosAPIService } from './apiService';
|
|
6
|
+
import { MissingRelatedPhotosError } from './errors';
|
|
7
|
+
import { DecryptedPhotoNode } from './interface';
|
|
8
|
+
import { PhotosNodesAccess } from './nodes';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Helper to create a mock photo node with minimal required properties.
|
|
12
|
+
*/
|
|
13
|
+
function createMockPhotoNode(
|
|
14
|
+
uid: string,
|
|
15
|
+
overrides: Partial<DecryptedPhotoNode> = {},
|
|
16
|
+
): DecryptedPhotoNode {
|
|
17
|
+
return {
|
|
18
|
+
uid,
|
|
19
|
+
parentUid: 'volume1~parent',
|
|
20
|
+
hash: 'hash',
|
|
21
|
+
photo: {
|
|
22
|
+
captureTime: new Date(),
|
|
23
|
+
mainPhotoNodeUid: undefined,
|
|
24
|
+
relatedPhotoNodeUids: [],
|
|
25
|
+
tags: [],
|
|
26
|
+
albums: [],
|
|
27
|
+
},
|
|
28
|
+
activeRevision: {
|
|
29
|
+
ok: true,
|
|
30
|
+
value: {
|
|
31
|
+
uid: 'rev1',
|
|
32
|
+
state: 'active' as const,
|
|
33
|
+
creationTime: new Date(),
|
|
34
|
+
storageSize: 100,
|
|
35
|
+
signatureEmail: 'test@example.com',
|
|
36
|
+
claimedModificationTime: new Date(),
|
|
37
|
+
claimedSize: 100,
|
|
38
|
+
claimedDigests: { sha1: 'sha1hash' },
|
|
39
|
+
claimedBlockSizes: [100],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
keyAuthor: { ok: true, value: 'test@example.com' },
|
|
43
|
+
...overrides,
|
|
44
|
+
} as DecryptedPhotoNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('AddToAlbumProcess', () => {
|
|
48
|
+
let apiService: jest.Mocked<PhotosAPIService>;
|
|
49
|
+
let cryptoService: jest.Mocked<AlbumsCryptoService>;
|
|
50
|
+
let nodesService: jest.Mocked<PhotosNodesAccess>;
|
|
51
|
+
let albumKeys: { key: unknown; hashKey: Uint8Array; passphrase: string; passphraseSessionKey: unknown };
|
|
52
|
+
let signingKeys: { type: 'userAddress'; email: string; addressId: string; key: unknown };
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
albumKeys = {
|
|
56
|
+
key: 'albumKey' as any,
|
|
57
|
+
hashKey: new Uint8Array([1, 2, 3]),
|
|
58
|
+
passphrase: 'passphrase',
|
|
59
|
+
passphraseSessionKey: 'passphraseSessionKey' as any,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
signingKeys = {
|
|
63
|
+
type: 'userAddress',
|
|
64
|
+
email: 'test@example.com',
|
|
65
|
+
addressId: 'addressId',
|
|
66
|
+
key: 'signingKey' as any,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// @ts-expect-error Mocking for testing purposes
|
|
70
|
+
apiService = {
|
|
71
|
+
addPhotosToAlbum: jest.fn(),
|
|
72
|
+
copyPhotoToAlbum: jest.fn(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// @ts-expect-error Mocking for testing purposes
|
|
76
|
+
cryptoService = {
|
|
77
|
+
encryptPhotoForAlbum: jest.fn(),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// @ts-expect-error Mocking for testing purposes
|
|
81
|
+
nodesService = {
|
|
82
|
+
iterateNodes: jest.fn(),
|
|
83
|
+
getNodePrivateAndSessionKeys: jest.fn(),
|
|
84
|
+
notifyNodeChanged: jest.fn(),
|
|
85
|
+
notifyChildCreated: jest.fn(),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function executeProcess(photoUids: string[]): Promise<NodeResultWithError[]> {
|
|
90
|
+
const process = new AddToAlbumProcess(
|
|
91
|
+
'volume1~album',
|
|
92
|
+
albumKeys as any,
|
|
93
|
+
signingKeys as any,
|
|
94
|
+
apiService,
|
|
95
|
+
cryptoService,
|
|
96
|
+
nodesService,
|
|
97
|
+
getMockLogger(),
|
|
98
|
+
);
|
|
99
|
+
return Array.fromAsync(process.execute(photoUids));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
nodesService.iterateNodes.mockImplementation(async function* (uids) {
|
|
104
|
+
for (const uid of uids) {
|
|
105
|
+
const photoNode = createMockPhotoNode(uid);
|
|
106
|
+
|
|
107
|
+
// Handle uids in the form 'volumeId~mainPhoto-related:X' where X is the number of related photos
|
|
108
|
+
const relatedMatch = /^(.+)~(.+)-related:(\d+)$/.exec(uid);
|
|
109
|
+
if (relatedMatch) {
|
|
110
|
+
const [, volumeId, mainPhoto, countStr] = relatedMatch;
|
|
111
|
+
const count = parseInt(countStr, 10);
|
|
112
|
+
photoNode.photo!.relatedPhotoNodeUids = Array.from({ length: count }, (_, idx) => `${volumeId}~related${idx + 1}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
yield photoNode;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
nodesService.getNodePrivateAndSessionKeys.mockResolvedValue({
|
|
120
|
+
key: 'nodeKey' as any,
|
|
121
|
+
nameSessionKey: 'sessionKey' as any,
|
|
122
|
+
passphrase: 'passphrase',
|
|
123
|
+
passphraseSessionKey: 'passphraseSessionKey' as any,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
cryptoService.encryptPhotoForAlbum.mockResolvedValue({
|
|
127
|
+
contentHash: 'contentHash',
|
|
128
|
+
hash: 'nameHash',
|
|
129
|
+
encryptedName: 'encryptedName',
|
|
130
|
+
nameSignatureEmail: 'test@example.com',
|
|
131
|
+
armoredNodePassphrase: 'passphrase',
|
|
132
|
+
armoredNodePassphraseSignature: 'signature',
|
|
133
|
+
signatureEmail: 'test@example.com',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
let addToAlbumReturnedMissing = false;
|
|
137
|
+
apiService.addPhotosToAlbum.mockImplementation(async function* (albumUid, payloads) {
|
|
138
|
+
for (const payload of payloads) {
|
|
139
|
+
let error: Error | undefined;
|
|
140
|
+
if (payload.nodeUid.includes('missingRelatedTwice')) {
|
|
141
|
+
error = new MissingRelatedPhotosError(['volume1~missingRelatedTwice1']);
|
|
142
|
+
addToAlbumReturnedMissing = true;
|
|
143
|
+
}
|
|
144
|
+
if (!addToAlbumReturnedMissing && payload.nodeUid.includes('missingRelatedOnce')) {
|
|
145
|
+
error = new MissingRelatedPhotosError(['volume1~missingRelatedOnce1']);
|
|
146
|
+
addToAlbumReturnedMissing = true;
|
|
147
|
+
}
|
|
148
|
+
if (error) {
|
|
149
|
+
yield { uid: payload.nodeUid, ok: false, error };
|
|
150
|
+
} else {
|
|
151
|
+
yield { uid: payload.nodeUid, ok: true };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
let copyToAlbumReturnedMissing = false;
|
|
157
|
+
apiService.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => {
|
|
158
|
+
let error: Error | undefined;
|
|
159
|
+
if (payload.nodeUid.includes('missingRelatedTwice')) {
|
|
160
|
+
error = new MissingRelatedPhotosError(['volume2~missingRelatedTwice1']);
|
|
161
|
+
copyToAlbumReturnedMissing = true;
|
|
162
|
+
}
|
|
163
|
+
if (!copyToAlbumReturnedMissing && payload.nodeUid.includes('missingRelatedOnce')) {
|
|
164
|
+
error = new MissingRelatedPhotosError(['volume2~missingRelatedOnce1']);
|
|
165
|
+
copyToAlbumReturnedMissing = true;
|
|
166
|
+
}
|
|
167
|
+
if (error) {
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
return `volume1~copied${payload.nodeUid}`;
|
|
171
|
+
});
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('Adding photos to the same volume', () => {
|
|
175
|
+
it('should prepare photo payloads in parallel without blocking', async () => {
|
|
176
|
+
// Setup: 25 photos (more than BATCH_LOADING_SIZE of 20)
|
|
177
|
+
const photoUids = Array.from({ length: 25 }, (_, i) => `volume1~photo${i}`);
|
|
178
|
+
|
|
179
|
+
let addPhotosCallCount = 0;
|
|
180
|
+
apiService.addPhotosToAlbum.mockImplementation(async function* (albumUid, payloads) {
|
|
181
|
+
addPhotosCallCount++;
|
|
182
|
+
|
|
183
|
+
// First call should happen before all 25 photos are prepared
|
|
184
|
+
// (should only have first batch of 20 prepared)
|
|
185
|
+
if (addPhotosCallCount === 1) {
|
|
186
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const payload of payloads) {
|
|
190
|
+
yield { uid: payload.nodeUid, ok: true };
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const results = await executeProcess(photoUids);
|
|
195
|
+
|
|
196
|
+
expect(results).toHaveLength(25);
|
|
197
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2);
|
|
198
|
+
expect(nodesService.iterateNodes.mock.calls[0][0]).toHaveLength(20);
|
|
199
|
+
expect(nodesService.iterateNodes.mock.calls[1][0]).toHaveLength(5);
|
|
200
|
+
expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(3);
|
|
201
|
+
expect(apiService.addPhotosToAlbum.mock.calls[0][1].length).toBe(10);
|
|
202
|
+
expect(apiService.addPhotosToAlbum.mock.calls[1][1].length).toBe(10);
|
|
203
|
+
expect(apiService.addPhotosToAlbum.mock.calls[2][1].length).toBe(5);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should include related photos in the same batch even if it exceeds batch size', async () => {
|
|
207
|
+
// Create a photo with 15 related photos (total size = 16, which exceeds batch size of 10)
|
|
208
|
+
const mainPhotoUid = 'volume1~mainPhoto-related:15';
|
|
209
|
+
|
|
210
|
+
const results = await executeProcess([mainPhotoUid]);
|
|
211
|
+
|
|
212
|
+
expect(results).toMatchObject([{
|
|
213
|
+
uid: mainPhotoUid,
|
|
214
|
+
ok: true,
|
|
215
|
+
}])
|
|
216
|
+
|
|
217
|
+
expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(1);
|
|
218
|
+
const params = apiService.addPhotosToAlbum.mock.calls[0];
|
|
219
|
+
expect(params[1].length).toBe(1);
|
|
220
|
+
expect(params[1][0].relatedPhotos?.length).toBe(15);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should re-queue photo when missing related photos error occurs', async () => {
|
|
224
|
+
const photoUid = 'volume1~mainPhoto-related:1-missingRelatedOnce';
|
|
225
|
+
|
|
226
|
+
const process = new AddToAlbumProcess(
|
|
227
|
+
'volume1~album',
|
|
228
|
+
albumKeys as any,
|
|
229
|
+
signingKeys as any,
|
|
230
|
+
apiService,
|
|
231
|
+
cryptoService,
|
|
232
|
+
nodesService,
|
|
233
|
+
getMockLogger(),
|
|
234
|
+
);
|
|
235
|
+
const results = await Array.fromAsync(process.execute([photoUid]));
|
|
236
|
+
|
|
237
|
+
expect(results).toMatchObject([{
|
|
238
|
+
uid: photoUid,
|
|
239
|
+
ok: true,
|
|
240
|
+
}])
|
|
241
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo
|
|
242
|
+
expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should return error if missing related photos error occurs twice', async () => {
|
|
246
|
+
const photoUid = 'volume1~photo1-missingRelatedTwice';
|
|
247
|
+
|
|
248
|
+
const results = await executeProcess([photoUid]);
|
|
249
|
+
|
|
250
|
+
expect(results).toMatchObject([{
|
|
251
|
+
uid: photoUid,
|
|
252
|
+
ok: false,
|
|
253
|
+
error: new MissingRelatedPhotosError(['volume1~missingRelatedOnce1']),
|
|
254
|
+
}])
|
|
255
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo
|
|
256
|
+
expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should return error when crypto service fails', async () => {
|
|
260
|
+
const photoUid = 'volume1~photo1';
|
|
261
|
+
|
|
262
|
+
const cryptoError = new Error('Crypto operation failed');
|
|
263
|
+
cryptoService.encryptPhotoForAlbum.mockRejectedValue(cryptoError);
|
|
264
|
+
|
|
265
|
+
const results = await executeProcess([photoUid]);
|
|
266
|
+
|
|
267
|
+
expect(results).toMatchObject([{
|
|
268
|
+
uid: photoUid,
|
|
269
|
+
ok: false,
|
|
270
|
+
error: cryptoError,
|
|
271
|
+
}])
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should return error when getNodePrivateAndSessionKeys fails', async () => {
|
|
275
|
+
const photoUid = 'volume1~photo1';
|
|
276
|
+
|
|
277
|
+
const keysError = new Error('Failed to get keys');
|
|
278
|
+
nodesService.getNodePrivateAndSessionKeys.mockRejectedValue(keysError);
|
|
279
|
+
|
|
280
|
+
const results = await executeProcess([photoUid]);
|
|
281
|
+
|
|
282
|
+
expect(results).toMatchObject([{
|
|
283
|
+
uid: photoUid,
|
|
284
|
+
ok: false,
|
|
285
|
+
error: keysError,
|
|
286
|
+
}])
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should notify node changed for successfully added photos', async () => {
|
|
290
|
+
const photoUid = 'volume1~photo1';
|
|
291
|
+
const results = await executeProcess([photoUid]);
|
|
292
|
+
|
|
293
|
+
expect(results).toMatchObject([{
|
|
294
|
+
uid: photoUid,
|
|
295
|
+
ok: true,
|
|
296
|
+
}])
|
|
297
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(1);
|
|
298
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(photoUid);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should not notify node changed for failed photos', async () => {
|
|
302
|
+
const photoUid = 'volume1~photo1';
|
|
303
|
+
|
|
304
|
+
apiService.addPhotosToAlbum.mockImplementation(async function* (albumUid, payloads) {
|
|
305
|
+
yield { uid: photoUid, ok: false, error: new Error('API error') };
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const results = await executeProcess([photoUid]);
|
|
309
|
+
|
|
310
|
+
expect(results).toMatchObject([{
|
|
311
|
+
uid: photoUid,
|
|
312
|
+
ok: false,
|
|
313
|
+
error: new Error('API error'),
|
|
314
|
+
}])
|
|
315
|
+
expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('Adding photos to a different volume', () => {
|
|
320
|
+
it('should prepare photo payloads in parallel without blocking', async () => {
|
|
321
|
+
// Setup: 25 photos from different volume (more than BATCH_LOADING_SIZE of 20)
|
|
322
|
+
const photoUids = Array.from({ length: 25 }, (_, i) => `volume2~photo${i}`);
|
|
323
|
+
|
|
324
|
+
let copyPhotoCallCount = 0;
|
|
325
|
+
apiService.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => {
|
|
326
|
+
copyPhotoCallCount++;
|
|
327
|
+
|
|
328
|
+
// First few calls should happen before all 25 photos are prepared
|
|
329
|
+
if (copyPhotoCallCount <= 20) {
|
|
330
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return `volume1~copied${copyPhotoCallCount}`;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const results = await executeProcess(photoUids);
|
|
337
|
+
|
|
338
|
+
expect(results).toHaveLength(25);
|
|
339
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2);
|
|
340
|
+
expect(nodesService.iterateNodes.mock.calls[0][0]).toHaveLength(20);
|
|
341
|
+
expect(nodesService.iterateNodes.mock.calls[1][0]).toHaveLength(5);
|
|
342
|
+
expect(copyPhotoCallCount).toBe(25);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should include related photos in copy request', async () => {
|
|
346
|
+
const mainPhotoUid = 'volume2~mainPhoto-related:15';
|
|
347
|
+
|
|
348
|
+
const results = await executeProcess([mainPhotoUid]);
|
|
349
|
+
|
|
350
|
+
expect(results).toMatchObject([{
|
|
351
|
+
uid: mainPhotoUid,
|
|
352
|
+
ok: true,
|
|
353
|
+
}])
|
|
354
|
+
expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(1);
|
|
355
|
+
const params = apiService.copyPhotoToAlbum.mock.calls[0];
|
|
356
|
+
expect(params[1].relatedPhotos?.length).toBe(15);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should re-queue photo when missing related photos error occurs', async () => {
|
|
360
|
+
const photoUid = 'volume2~photo1-related:1-missingRelatedOnce';
|
|
361
|
+
|
|
362
|
+
const results = await executeProcess([photoUid]);
|
|
363
|
+
|
|
364
|
+
expect(results).toMatchObject([{
|
|
365
|
+
uid: photoUid,
|
|
366
|
+
ok: true,
|
|
367
|
+
}]);
|
|
368
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo
|
|
369
|
+
expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should return error if missing related photos error occurs twice', async () => {
|
|
373
|
+
const photoUid = 'volume2~photo1-missingRelatedTwice';
|
|
374
|
+
|
|
375
|
+
const results = await executeProcess([photoUid]);
|
|
376
|
+
|
|
377
|
+
expect(results).toMatchObject([{
|
|
378
|
+
uid: photoUid,
|
|
379
|
+
ok: false,
|
|
380
|
+
error: new MissingRelatedPhotosError(['volume2~missingRelatedOnce1']),
|
|
381
|
+
}]);
|
|
382
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo
|
|
383
|
+
expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should return error when crypto service fails', async () => {
|
|
387
|
+
const photoUid = 'volume2~photo1';
|
|
388
|
+
|
|
389
|
+
const cryptoError = new Error('Crypto operation failed');
|
|
390
|
+
cryptoService.encryptPhotoForAlbum.mockRejectedValue(cryptoError);
|
|
391
|
+
|
|
392
|
+
const results = await executeProcess([photoUid]);
|
|
393
|
+
|
|
394
|
+
expect(results).toMatchObject([{
|
|
395
|
+
uid: photoUid,
|
|
396
|
+
ok: false,
|
|
397
|
+
error: cryptoError,
|
|
398
|
+
}]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should return error when getNodePrivateAndSessionKeys fails', async () => {
|
|
402
|
+
const photoUid = 'volume2~photo1';
|
|
403
|
+
|
|
404
|
+
const keysError = new Error('Failed to get keys');
|
|
405
|
+
nodesService.getNodePrivateAndSessionKeys.mockRejectedValue(keysError);
|
|
406
|
+
|
|
407
|
+
const results = await executeProcess([photoUid]);
|
|
408
|
+
|
|
409
|
+
expect(results).toMatchObject([{
|
|
410
|
+
uid: photoUid,
|
|
411
|
+
ok: false,
|
|
412
|
+
error: keysError,
|
|
413
|
+
}]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should notify child created for successfully copied photos', async () => {
|
|
417
|
+
const photoUid = 'volume2~photo1';
|
|
418
|
+
const results = await executeProcess([photoUid]);
|
|
419
|
+
|
|
420
|
+
expect(results).toMatchObject([{
|
|
421
|
+
uid: photoUid,
|
|
422
|
+
ok: true,
|
|
423
|
+
}])
|
|
424
|
+
expect(nodesService.notifyChildCreated).toHaveBeenCalledTimes(1);
|
|
425
|
+
expect(nodesService.notifyChildCreated.mock.calls[0][0]).toContain('volume1~copied');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should not notify for failed photo copies', async () => {
|
|
429
|
+
const photoUid = 'volume2~photo1';
|
|
430
|
+
|
|
431
|
+
apiService.copyPhotoToAlbum.mockRejectedValue(new Error('API error'));
|
|
432
|
+
|
|
433
|
+
const results = await executeProcess([photoUid]);
|
|
434
|
+
|
|
435
|
+
expect(results).toMatchObject([{
|
|
436
|
+
uid: photoUid,
|
|
437
|
+
ok: false,
|
|
438
|
+
error: new Error('API error'),
|
|
439
|
+
}])
|
|
440
|
+
expect(nodesService.notifyChildCreated).not.toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe('Adding photos from both same and different volumes', () => {
|
|
445
|
+
it('should process same volume photos first, then different volume photos', async () => {
|
|
446
|
+
const sameVolumeUids = ['volume1~photo1', 'volume1~photo2'];
|
|
447
|
+
const differentVolumeUids = ['volume2~photo3', 'volume2~photo4'];
|
|
448
|
+
const allUids = [...sameVolumeUids, ...differentVolumeUids];
|
|
449
|
+
|
|
450
|
+
const results = await executeProcess(allUids);
|
|
451
|
+
|
|
452
|
+
expect(results).toMatchObject([{
|
|
453
|
+
uid: sameVolumeUids[0],
|
|
454
|
+
ok: true,
|
|
455
|
+
}, {
|
|
456
|
+
uid: sameVolumeUids[1],
|
|
457
|
+
ok: true,
|
|
458
|
+
}, {
|
|
459
|
+
uid: differentVolumeUids[0],
|
|
460
|
+
ok: true,
|
|
461
|
+
}, {
|
|
462
|
+
uid: differentVolumeUids[1],
|
|
463
|
+
ok: true,
|
|
464
|
+
}]);
|
|
465
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2);
|
|
466
|
+
expect(nodesService.iterateNodes.mock.calls[0][0]).toMatchObject(sameVolumeUids);
|
|
467
|
+
expect(nodesService.iterateNodes.mock.calls[1][0]).toMatchObject(differentVolumeUids);
|
|
468
|
+
expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(1);
|
|
469
|
+
expect(apiService.addPhotosToAlbum.mock.calls[0][1].map(({ nodeUid }) => nodeUid)).toMatchObject(sameVolumeUids);
|
|
470
|
+
expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2);
|
|
471
|
+
expect(apiService.copyPhotoToAlbum.mock.calls[0][1].nodeUid).toBe(differentVolumeUids[0]);
|
|
472
|
+
expect(apiService.copyPhotoToAlbum.mock.calls[1][1].nodeUid).toBe(differentVolumeUids[1]);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should prepare payloads in parallel for both queues', async () => {
|
|
476
|
+
// 25 photos from same volume, 25 from different volume
|
|
477
|
+
const sameVolumeUids = Array.from({ length: 25 }, (_, i) => `volume1~photo${i}`);
|
|
478
|
+
const differentVolumeUids = Array.from({ length: 25 }, (_, i) => `volume2~photo${i}`);
|
|
479
|
+
const allUids = [...sameVolumeUids, ...differentVolumeUids];
|
|
480
|
+
|
|
481
|
+
const results = await executeProcess(allUids);
|
|
482
|
+
|
|
483
|
+
expect(results).toHaveLength(50);
|
|
484
|
+
// Each volume should have been loaded in 2 batches (20 + 5)
|
|
485
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2 + 2);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('should handle retries correctly for both volumes', async () => {
|
|
489
|
+
const sameVolumeUid = 'volume1~photo1-related:1-missingRelatedOnce';
|
|
490
|
+
const differentVolumeUid = 'volume2~photo2-related:1-missingRelatedOnce';
|
|
491
|
+
|
|
492
|
+
const results = await executeProcess([sameVolumeUid, differentVolumeUid]);
|
|
493
|
+
|
|
494
|
+
expect(results).toHaveLength(2);
|
|
495
|
+
expect(results[0].ok).toBe(true);
|
|
496
|
+
expect(results[1].ok).toBe(true);
|
|
497
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3 + 3); // main photo + related photo + missing related photo
|
|
498
|
+
expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts
|
|
499
|
+
expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should notify correctly for both volumes', async () => {
|
|
503
|
+
const sameVolumeUid = 'volume1~photo1';
|
|
504
|
+
const differentVolumeUid = 'volume2~photo2';
|
|
505
|
+
|
|
506
|
+
const results = await executeProcess([sameVolumeUid, differentVolumeUid]);
|
|
507
|
+
|
|
508
|
+
expect(results).toHaveLength(2);
|
|
509
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(1);
|
|
510
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(sameVolumeUid);
|
|
511
|
+
expect(nodesService.notifyChildCreated).toHaveBeenCalledTimes(1);
|
|
512
|
+
expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('volume1~copiedvolume2~photo2');
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
});
|