mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
1579 lines
49 KiB
TypeScript
1579 lines
49 KiB
TypeScript
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
/* eslint-disable more/no-then */
|
|
/* eslint-disable @typescript-eslint/no-floating-promises */
|
|
import * as sinon from 'sinon';
|
|
import { assert } from 'chai';
|
|
import lodash, { pick } from 'lodash';
|
|
import { type StatsFs } from 'node:fs';
|
|
import { v7 } from 'uuid';
|
|
import { emptyDir, ensureFile } from 'fs-extra';
|
|
|
|
import * as MIME from '../../types/MIME.std.js';
|
|
import {
|
|
AttachmentDownloadManager,
|
|
runDownloadAttachmentJob,
|
|
runDownloadAttachmentJobInner,
|
|
type NewAttachmentDownloadJobType,
|
|
} from '../../jobs/AttachmentDownloadManager.preload.js';
|
|
import {
|
|
type AttachmentDownloadJobType,
|
|
AttachmentDownloadUrgency,
|
|
MediaTier,
|
|
} from '../../types/AttachmentDownload.std.js';
|
|
import { DataReader, DataWriter } from '../../sql/Client.preload.js';
|
|
import { DAY, MINUTE, MONTH } from '../../util/durations/index.std.js';
|
|
import {
|
|
type AttachmentType,
|
|
AttachmentVariant,
|
|
} from '../../types/Attachment.std.js';
|
|
import { strictAssert } from '../../util/assert.std.js';
|
|
import type { downloadAttachment as downloadAttachmentUtil } from '../../util/downloadAttachment.preload.js';
|
|
import { AttachmentDownloadSource } from '../../sql/Interface.std.js';
|
|
import { generateAttachmentKeys } from '../../AttachmentCrypto.node.js';
|
|
import { getAttachmentCiphertextSize } from '../../util/AttachmentCrypto.std.js';
|
|
import { MEBIBYTE } from '../../types/AttachmentSize.std.js';
|
|
import { generateAci } from '../../types/ServiceId.std.js';
|
|
import { toBase64 } from '../../Bytes.std.js';
|
|
import { JobCancelReason } from '../../jobs/types.std.js';
|
|
import {
|
|
explodePromise,
|
|
type ExplodePromiseResultType,
|
|
} from '../../util/explodePromise.std.js';
|
|
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
|
import { composeAttachment } from '../../test-node/util/queueAttachmentDownloads_test.preload.js';
|
|
import { MessageCache } from '../../services/MessageCache.preload.js';
|
|
import { AttachmentNotNeededForMessageError } from '../../messageModifiers/AttachmentDownloads.preload.js';
|
|
import {
|
|
testAttachmentDigest,
|
|
testAttachmentKey,
|
|
testAttachmentLocalKey,
|
|
testPlaintextHash,
|
|
} from '../../test-helpers/attachments.node.js';
|
|
import type { MessageAttributesType } from '../../model-types.js';
|
|
import { getAttachmentsPath } from '../../../app/attachments.node.js';
|
|
import { getAbsoluteAttachmentPath } from '../../util/migrations.preload.js';
|
|
|
|
const { omit } = lodash;
|
|
|
|
function composeJob({
|
|
messageId,
|
|
receivedAt,
|
|
attachmentOverrides,
|
|
jobOverrides,
|
|
}: Pick<NewAttachmentDownloadJobType, 'messageId' | 'receivedAt'> & {
|
|
attachmentOverrides?: Partial<AttachmentType>;
|
|
jobOverrides?: Partial<AttachmentDownloadJobType>;
|
|
}): AttachmentDownloadJobType {
|
|
const digest = `digestFor${messageId}`;
|
|
const plaintextHash = testPlaintextHash();
|
|
const size = 128;
|
|
const contentType = MIME.IMAGE_PNG;
|
|
return {
|
|
messageId,
|
|
receivedAt,
|
|
sentAt: receivedAt,
|
|
attachmentType: 'attachment',
|
|
attachmentSignature: `${digest}.${plaintextHash}`,
|
|
size,
|
|
ciphertextSize: getAttachmentCiphertextSize({
|
|
unpaddedPlaintextSize: size,
|
|
mediaTier: MediaTier.STANDARD,
|
|
}),
|
|
contentType,
|
|
active: false,
|
|
attempts: 0,
|
|
retryAfter: null,
|
|
lastAttemptTimestamp: null,
|
|
originalSource: jobOverrides?.source ?? AttachmentDownloadSource.STANDARD,
|
|
source: AttachmentDownloadSource.STANDARD,
|
|
attachment: {
|
|
contentType,
|
|
size,
|
|
digest,
|
|
plaintextHash,
|
|
key: testAttachmentKey(),
|
|
...attachmentOverrides,
|
|
},
|
|
...jobOverrides,
|
|
};
|
|
}
|
|
|
|
// node-fetch does not export AbortError as a constructor, so we copy it here
|
|
class AbortError extends Error {
|
|
readonly type = 'aborted';
|
|
override name = 'AbortError';
|
|
}
|
|
|
|
describe('AttachmentDownloadManager', () => {
|
|
let downloadManager: AttachmentDownloadManager | undefined;
|
|
let runJob: sinon.SinonStub<
|
|
Parameters<typeof runDownloadAttachmentJob>,
|
|
ReturnType<typeof runDownloadAttachmentJob>
|
|
>;
|
|
let sandbox: sinon.SinonSandbox;
|
|
let clock: sinon.SinonFakeTimers;
|
|
let hasMediaBackups: sinon.SinonStub;
|
|
let isInCall: sinon.SinonStub;
|
|
let onLowDiskSpaceBackupImport: sinon.SinonStub;
|
|
let statfs: sinon.SinonStub;
|
|
|
|
beforeEach(async () => {
|
|
await DataWriter.removeAll();
|
|
await itemStorage.user.setAciAndDeviceId(generateAci(), 1);
|
|
MessageCache.install();
|
|
|
|
sandbox = sinon.createSandbox();
|
|
clock = sandbox.useFakeTimers();
|
|
|
|
hasMediaBackups = sandbox.stub().returns(true);
|
|
isInCall = sandbox.stub().returns(false);
|
|
onLowDiskSpaceBackupImport = sandbox
|
|
.stub()
|
|
.callsFake(async () =>
|
|
itemStorage.put('backupMediaDownloadPaused', true)
|
|
);
|
|
runJob = sandbox
|
|
.stub<
|
|
Parameters<typeof runDownloadAttachmentJob>,
|
|
ReturnType<typeof runDownloadAttachmentJob>
|
|
>()
|
|
.callsFake(async () => {
|
|
return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
|
|
Promise.resolve().then(() => {
|
|
resolve({ status: 'finished' });
|
|
});
|
|
});
|
|
});
|
|
statfs = sandbox.stub().callsFake(() =>
|
|
Promise.resolve({
|
|
bavail: 100_000_000_000,
|
|
bsize: 100,
|
|
} as StatsFs)
|
|
);
|
|
|
|
downloadManager = new AttachmentDownloadManager({
|
|
...AttachmentDownloadManager.defaultParams,
|
|
saveJob: DataWriter.saveAttachmentDownloadJob,
|
|
shouldHoldOffOnStartingQueuedJobs: isInCall,
|
|
runDownloadAttachmentJob: runJob,
|
|
getRetryConfig: () => ({
|
|
maxAttempts: 5,
|
|
backoffConfig: {
|
|
multiplier: 2,
|
|
firstBackoffs: [MINUTE],
|
|
maxBackoffTime: 10 * MINUTE,
|
|
},
|
|
}),
|
|
onLowDiskSpaceBackupImport,
|
|
hasMediaBackups,
|
|
getMessageQueueTime: () => 45 * DAY,
|
|
statfs,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await downloadManager?.stop();
|
|
sandbox.restore();
|
|
await DataWriter.removeAll();
|
|
await itemStorage.fetch();
|
|
});
|
|
|
|
async function addJob(
|
|
job: AttachmentDownloadJobType,
|
|
urgency: AttachmentDownloadUrgency
|
|
) {
|
|
// Save message first to satisfy foreign key constraint
|
|
await window.MessageCache.saveMessage(
|
|
{
|
|
id: job.messageId,
|
|
type: 'incoming',
|
|
sent_at: job.sentAt,
|
|
timestamp: job.sentAt,
|
|
received_at: job.receivedAt + 1,
|
|
conversationId: 'convoId',
|
|
attachments: [job.attachment],
|
|
},
|
|
{
|
|
forceSave: true,
|
|
}
|
|
);
|
|
await downloadManager?.addJob({
|
|
urgency,
|
|
...job,
|
|
isManualDownload: Boolean(job.isManualDownload),
|
|
});
|
|
}
|
|
async function addJobs(
|
|
num: number,
|
|
jobOverrides?:
|
|
| Partial<AttachmentDownloadJobType>
|
|
| ((idx: number) => Partial<AttachmentDownloadJobType>),
|
|
attachmentOverrides?: Partial<AttachmentType>
|
|
): Promise<Array<AttachmentDownloadJobType>> {
|
|
const jobs = new Array(num).fill(null).map((_, idx) =>
|
|
composeJob({
|
|
messageId: `message-${idx}`,
|
|
receivedAt: idx,
|
|
jobOverrides:
|
|
typeof jobOverrides === 'function' ? jobOverrides(idx) : jobOverrides,
|
|
attachmentOverrides,
|
|
})
|
|
);
|
|
for (const job of jobs) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await addJob(job, AttachmentDownloadUrgency.STANDARD);
|
|
}
|
|
return jobs;
|
|
}
|
|
|
|
function waitForJobToBeStarted(job: AttachmentDownloadJobType) {
|
|
return downloadManager?.waitForJobToBeStarted(job);
|
|
}
|
|
|
|
function waitForJobToBeCompleted(job: AttachmentDownloadJobType) {
|
|
return downloadManager?.waitForJobToBeCompleted(job);
|
|
}
|
|
|
|
function assertRunJobCalledWith(jobs: Array<AttachmentDownloadJobType>) {
|
|
return assert.strictEqual(
|
|
JSON.stringify(
|
|
runJob
|
|
.getCalls()
|
|
.map(
|
|
call =>
|
|
`${call.args[0].job.messageId}${call.args[0].job.attachmentType}.${call.args[0].job.attachmentSignature}`
|
|
)
|
|
),
|
|
JSON.stringify(
|
|
jobs.map(
|
|
job =>
|
|
`${job.messageId}${job.attachmentType}.${job.attachmentSignature}`
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
async function flushSQLReads() {
|
|
await DataWriter.getNextAttachmentDownloadJobs({ limit: 10 });
|
|
}
|
|
|
|
async function advanceTime(ms: number) {
|
|
// When advancing the timers, we want to make sure any DB operations are completed
|
|
// first. In cases like maybeStartJobs where we prevent re-entrancy, without this,
|
|
// prior (unfinished) invocations can prevent subsequent calls after the clock is
|
|
// ticked forward and make tests unreliable
|
|
await flushSQLReads();
|
|
const now = Date.now();
|
|
while (Date.now() < now + ms) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await clock.tickAsync(downloadManager?.tickInterval ?? 1000);
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await flushSQLReads();
|
|
}
|
|
}
|
|
|
|
function getPromisesForAttempts(
|
|
job: AttachmentDownloadJobType,
|
|
attempts: number
|
|
) {
|
|
return new Array(attempts).fill(null).map((_, idx) => {
|
|
return {
|
|
started: waitForJobToBeStarted({ ...job, attempts: idx }),
|
|
completed: waitForJobToBeCompleted({ ...job, attempts: idx }),
|
|
};
|
|
});
|
|
}
|
|
|
|
it('runs 3 jobs at a time in descending receivedAt order', async () => {
|
|
const jobs = await addJobs(5);
|
|
// Confirm they are saved to DB
|
|
const allJobs = await DataWriter.getNextAttachmentDownloadJobs({
|
|
limit: 100,
|
|
});
|
|
|
|
assert.strictEqual(allJobs.length, 5);
|
|
assert.strictEqual(
|
|
JSON.stringify(allJobs.map(job => job.messageId)),
|
|
JSON.stringify([
|
|
'message-4',
|
|
'message-3',
|
|
'message-2',
|
|
'message-1',
|
|
'message-0',
|
|
])
|
|
);
|
|
|
|
await downloadManager?.start();
|
|
await waitForJobToBeStarted(jobs[2]);
|
|
|
|
assert.strictEqual(runJob.callCount, 3);
|
|
assertRunJobCalledWith([jobs[4], jobs[3], jobs[2]]);
|
|
|
|
await waitForJobToBeStarted(jobs[0]);
|
|
assert.strictEqual(runJob.callCount, 5);
|
|
assertRunJobCalledWith([jobs[4], jobs[3], jobs[2], jobs[1], jobs[0]]);
|
|
});
|
|
|
|
it('runs a job immediately if urgency is IMMEDIATE', async () => {
|
|
const jobs = await addJobs(6);
|
|
await downloadManager?.start();
|
|
|
|
const urgentJobForOldMessage = composeJob({
|
|
messageId: 'message-urgent',
|
|
receivedAt: 0,
|
|
});
|
|
|
|
await addJob(urgentJobForOldMessage, AttachmentDownloadUrgency.IMMEDIATE);
|
|
|
|
await waitForJobToBeStarted(urgentJobForOldMessage);
|
|
|
|
assert.strictEqual(runJob.callCount, 4);
|
|
assertRunJobCalledWith([jobs[5], jobs[4], jobs[3], urgentJobForOldMessage]);
|
|
|
|
await waitForJobToBeStarted(jobs[0]);
|
|
assert.strictEqual(runJob.callCount, 7);
|
|
assertRunJobCalledWith([
|
|
jobs[5],
|
|
jobs[4],
|
|
jobs[3],
|
|
urgentJobForOldMessage,
|
|
jobs[2],
|
|
jobs[1],
|
|
jobs[0],
|
|
]);
|
|
});
|
|
|
|
it('prefers jobs for visible messages', async () => {
|
|
const jobs = await addJobs(5);
|
|
|
|
downloadManager?.updateVisibleTimelineMessages(['message-0', 'message-1']);
|
|
|
|
await downloadManager?.start();
|
|
|
|
await waitForJobToBeStarted(jobs[4]);
|
|
assert.strictEqual(runJob.callCount, 3);
|
|
assertRunJobCalledWith([jobs[0], jobs[1], jobs[4]]);
|
|
|
|
await waitForJobToBeStarted(jobs[2]);
|
|
assert.strictEqual(runJob.callCount, 5);
|
|
assertRunJobCalledWith([jobs[0], jobs[1], jobs[4], jobs[3], jobs[2]]);
|
|
});
|
|
|
|
it("does not start a job if we're in a call", async () => {
|
|
const jobs = await addJobs(5);
|
|
|
|
isInCall.callsFake(() => true);
|
|
|
|
await downloadManager?.start();
|
|
await advanceTime(2 * MINUTE);
|
|
assert.strictEqual(runJob.callCount, 0);
|
|
|
|
isInCall.callsFake(() => false);
|
|
|
|
await advanceTime(2 * MINUTE);
|
|
await waitForJobToBeStarted(jobs[0]);
|
|
assert.strictEqual(runJob.callCount, 5);
|
|
});
|
|
|
|
it('triggers onLowDiskSpace for backup import jobs', async () => {
|
|
const jobs = await addJobs(1, _idx => ({
|
|
source: AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA,
|
|
}));
|
|
|
|
const jobAttempts = getPromisesForAttempts(jobs[0], 2);
|
|
|
|
statfs.callsFake(() => Promise.resolve({ bavail: 0, bsize: 8 }));
|
|
|
|
await downloadManager?.start();
|
|
await jobAttempts[0].completed;
|
|
|
|
assert.strictEqual(runJob.callCount, 0);
|
|
assert.strictEqual(onLowDiskSpaceBackupImport.callCount, 1);
|
|
assert.isTrue(itemStorage.get('backupMediaDownloadPaused'));
|
|
|
|
statfs.callsFake(() =>
|
|
Promise.resolve({ bavail: 100_000_000_000, bsize: 8 })
|
|
);
|
|
await itemStorage.put('backupMediaDownloadPaused', false);
|
|
|
|
await advanceTime(2 * MINUTE);
|
|
assert.strictEqual(runJob.callCount, 1);
|
|
await jobAttempts[1].completed;
|
|
});
|
|
|
|
it('handles retries for failed', async () => {
|
|
const jobs = await addJobs(2);
|
|
const job0Attempts = getPromisesForAttempts(jobs[0], 1);
|
|
const job1Attempts = getPromisesForAttempts(jobs[1], 5);
|
|
|
|
runJob.callsFake(async ({ job }: { job: AttachmentDownloadJobType }) => {
|
|
return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
|
|
Promise.resolve().then(() => {
|
|
if (job.messageId === jobs[0].messageId) {
|
|
resolve({ status: 'finished' });
|
|
} else {
|
|
resolve({ status: 'retry' });
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
await downloadManager?.start();
|
|
|
|
await job0Attempts[0].completed;
|
|
assert.strictEqual(runJob.callCount, 2);
|
|
assertRunJobCalledWith([jobs[1], jobs[0]]);
|
|
|
|
const retriedJob = await DataReader._getAttachmentDownloadJob(jobs[1]);
|
|
const finishedJob = await DataReader._getAttachmentDownloadJob(jobs[0]);
|
|
|
|
assert.isUndefined(finishedJob);
|
|
assert.strictEqual(retriedJob?.attempts, 1);
|
|
assert.isNumber(retriedJob?.retryAfter);
|
|
|
|
await advanceTime(MINUTE);
|
|
|
|
await job1Attempts[1].completed;
|
|
assert.strictEqual(runJob.callCount, 3);
|
|
await advanceTime(2 * MINUTE);
|
|
|
|
await job1Attempts[2].completed;
|
|
assert.strictEqual(runJob.callCount, 4);
|
|
|
|
await advanceTime(4 * MINUTE);
|
|
await job1Attempts[3].completed;
|
|
assert.strictEqual(runJob.callCount, 5);
|
|
|
|
await advanceTime(8 * MINUTE);
|
|
await job1Attempts[4].completed;
|
|
|
|
assert.strictEqual(runJob.callCount, 6);
|
|
assertRunJobCalledWith([
|
|
jobs[1],
|
|
jobs[0],
|
|
jobs[1],
|
|
jobs[1],
|
|
jobs[1],
|
|
jobs[1],
|
|
]);
|
|
|
|
// Ensure it's been removed after completed
|
|
assert.isUndefined(await DataReader._getAttachmentDownloadJob(jobs[1]));
|
|
});
|
|
|
|
it('will reset attempts if addJob is called again', async () => {
|
|
const jobs = await addJobs(1);
|
|
runJob.callsFake(async () => {
|
|
return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
|
|
Promise.resolve().then(() => {
|
|
resolve({ status: 'retry' });
|
|
});
|
|
});
|
|
});
|
|
|
|
let attempts = getPromisesForAttempts(jobs[0], 4);
|
|
await downloadManager?.start();
|
|
|
|
await attempts[0].completed;
|
|
assert.strictEqual(runJob.callCount, 1);
|
|
|
|
await advanceTime(1 * MINUTE);
|
|
await attempts[1].completed;
|
|
assert.strictEqual(runJob.callCount, 2);
|
|
|
|
await advanceTime(5 * MINUTE);
|
|
await attempts[2].completed;
|
|
assert.strictEqual(runJob.callCount, 3);
|
|
|
|
// add the same job again and it should retry ASAP and reset attempts
|
|
attempts = getPromisesForAttempts(jobs[0], 5);
|
|
await downloadManager?.addJob({
|
|
...jobs[0],
|
|
isManualDownload: Boolean(jobs[0].isManualDownload),
|
|
});
|
|
await attempts[0].completed;
|
|
assert.strictEqual(runJob.callCount, 4);
|
|
|
|
await advanceTime(1 * MINUTE);
|
|
await attempts[1].completed;
|
|
assert.strictEqual(runJob.callCount, 5);
|
|
|
|
await advanceTime(2 * MINUTE);
|
|
await attempts[2].completed;
|
|
assert.strictEqual(runJob.callCount, 6);
|
|
|
|
await advanceTime(4 * MINUTE);
|
|
await attempts[3].completed;
|
|
assert.strictEqual(runJob.callCount, 7);
|
|
|
|
await advanceTime(8 * MINUTE);
|
|
await attempts[4].completed;
|
|
assert.strictEqual(runJob.callCount, 8);
|
|
|
|
// Ensure it's been removed
|
|
assert.isUndefined(await DataReader._getAttachmentDownloadJob(jobs[0]));
|
|
});
|
|
|
|
it('only selects backup_import jobs if the mediaDownload is not paused', async () => {
|
|
await itemStorage.put('backupMediaDownloadPaused', true);
|
|
|
|
const jobs = await addJobs(6, idx => ({
|
|
source:
|
|
idx % 2 === 0
|
|
? AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA
|
|
: AttachmentDownloadSource.STANDARD,
|
|
}));
|
|
// make one of the backup job messages visible to test that code path as well
|
|
downloadManager?.updateVisibleTimelineMessages(['message-0', 'message-1']);
|
|
await downloadManager?.start();
|
|
await waitForJobToBeCompleted(jobs[3]);
|
|
assertRunJobCalledWith([jobs[1], jobs[5], jobs[3]]);
|
|
await advanceTime((downloadManager?.tickInterval ?? MINUTE) * 5);
|
|
assertRunJobCalledWith([jobs[1], jobs[5], jobs[3]]);
|
|
|
|
// resume backups
|
|
await itemStorage.put('backupMediaDownloadPaused', false);
|
|
await advanceTime((downloadManager?.tickInterval ?? MINUTE) * 5);
|
|
assertRunJobCalledWith([
|
|
jobs[1],
|
|
jobs[5],
|
|
jobs[3],
|
|
jobs[0],
|
|
jobs[4],
|
|
jobs[2],
|
|
]);
|
|
});
|
|
|
|
it('retries backup job immediately if retryAfters are reset', async () => {
|
|
strictAssert(downloadManager, 'must exist');
|
|
const jobs = await addJobs(1, {
|
|
source: AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA,
|
|
});
|
|
const jobAttempts = getPromisesForAttempts(jobs[0], 2);
|
|
|
|
runJob.callsFake(async () => {
|
|
return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
|
|
Promise.resolve().then(() => {
|
|
resolve({ status: 'retry' });
|
|
});
|
|
});
|
|
});
|
|
|
|
await downloadManager?.start();
|
|
await jobAttempts[0].completed;
|
|
assertRunJobCalledWith([jobs[0]]);
|
|
|
|
await DataWriter.resetBackupAttachmentDownloadJobsRetryAfter();
|
|
await downloadManager.start();
|
|
|
|
await jobAttempts[1].completed;
|
|
});
|
|
|
|
it('retries job with updated job if provided', async () => {
|
|
strictAssert(downloadManager, 'must exist');
|
|
const job = (
|
|
await addJobs(1, {
|
|
source: AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA,
|
|
})
|
|
)[0];
|
|
const jobAttempts = getPromisesForAttempts(job, 3);
|
|
|
|
runJob.callsFake(async args => {
|
|
return new Promise(resolve => {
|
|
Promise.resolve().then(() => {
|
|
resolve({
|
|
status: 'retry',
|
|
updatedJob: {
|
|
...args.job,
|
|
attachment: { ...job.attachment, caption: 'retried' },
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
await downloadManager?.start();
|
|
await jobAttempts[0].completed;
|
|
assertRunJobCalledWith([job]);
|
|
await jobAttempts[1].completed;
|
|
assert.deepStrictEqual(
|
|
runJob.getCall(0).args[0].job.attachment,
|
|
job.attachment
|
|
);
|
|
assert.deepStrictEqual(runJob.getCall(1).args[0].job.attachment, {
|
|
...job.attachment,
|
|
caption: 'retried',
|
|
});
|
|
});
|
|
|
|
describe('handles aborts properly', () => {
|
|
let inflightRequestAbortController: AbortController;
|
|
let downloadStarted: ExplodePromiseResultType<void>;
|
|
|
|
beforeEach(() => {
|
|
inflightRequestAbortController = new AbortController();
|
|
downloadStarted = explodePromise<void>();
|
|
runJob.callsFake((...args) =>
|
|
runDownloadAttachmentJob({
|
|
...args[0],
|
|
dependencies: {
|
|
downloadAttachment: sandbox
|
|
.stub()
|
|
.callsFake(({ options: { abortSignal } }) => {
|
|
return new Promise((_resolve, reject) => {
|
|
abortSignal.addEventListener('abort', () => {
|
|
reject(new AbortError('aborted by job'));
|
|
});
|
|
|
|
inflightRequestAbortController.signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
reject(
|
|
new AbortError(
|
|
'aborted by in-flight requests cancellation'
|
|
)
|
|
);
|
|
}
|
|
);
|
|
downloadStarted.resolve();
|
|
});
|
|
}),
|
|
cleanupAttachmentFiles: sandbox.stub(),
|
|
deleteDownloadFile: sandbox.stub(),
|
|
processNewAttachment: sandbox.stub(),
|
|
maybeDeleteAttachmentFile: sandbox.stub(),
|
|
runDownloadAttachmentJobInner,
|
|
},
|
|
})
|
|
);
|
|
});
|
|
it('will retry a job when aborted b/c of shutdown', async () => {
|
|
const jobs = await addJobs(1);
|
|
const jobAttempts = getPromisesForAttempts(jobs[0], 2);
|
|
|
|
await downloadManager?.start();
|
|
await jobAttempts[0].started;
|
|
await downloadStarted.promise;
|
|
|
|
// Shutdown behavior
|
|
downloadManager?.stop();
|
|
inflightRequestAbortController.abort();
|
|
|
|
await jobAttempts[0].completed;
|
|
// Ensure it will be retried
|
|
assert.strictEqual(
|
|
(await DataReader._getAttachmentDownloadJob(jobs[0]))?.attempts,
|
|
1
|
|
);
|
|
assert.strictEqual(runJob.callCount, 1);
|
|
});
|
|
it('will not retry a job if manually cancelled', async () => {
|
|
const jobs = await addJobs(1);
|
|
const jobAttempts = getPromisesForAttempts(jobs[0], 2);
|
|
|
|
await downloadManager?.start();
|
|
const downloadManagerIdled = downloadManager?.waitForIdle();
|
|
|
|
await jobAttempts[0].started;
|
|
await downloadStarted.promise;
|
|
|
|
// user-cancelled behavior
|
|
downloadManager?.cancelJobs(JobCancelReason.UserInitiated, () => true);
|
|
|
|
await assert.isRejected(jobAttempts[0].completed as Promise<void>);
|
|
await downloadManagerIdled;
|
|
|
|
// Ensure it will not be retried
|
|
assert.isUndefined(await DataReader._getAttachmentDownloadJob(jobs[0]));
|
|
assert.strictEqual(runJob.callCount, 1);
|
|
});
|
|
});
|
|
|
|
describe('will drop jobs from non-media backup imports that are old', () => {
|
|
it('will not queue attachments older than 90 days (2 * message queue time)', async () => {
|
|
await addJobs(
|
|
1,
|
|
{
|
|
source: AttachmentDownloadSource.BACKUP_IMPORT_NO_MEDIA,
|
|
},
|
|
{ uploadTimestamp: Date.now() - 4 * MONTH }
|
|
);
|
|
|
|
const savedJobs = await DataWriter.getNextAttachmentDownloadJobs({
|
|
limit: 100,
|
|
});
|
|
assert.strictEqual(savedJobs.length, 0);
|
|
});
|
|
it('will queue old attachments with media backups on', async () => {
|
|
hasMediaBackups.returns(true);
|
|
await addJobs(
|
|
1,
|
|
{
|
|
source: AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA,
|
|
},
|
|
{ uploadTimestamp: Date.now() - 4 * MONTH }
|
|
);
|
|
|
|
const savedJobs = await DataWriter.getNextAttachmentDownloadJobs({
|
|
limit: 100,
|
|
});
|
|
assert.strictEqual(savedJobs.length, 1);
|
|
});
|
|
it('will queue old local backup attachments', async () => {
|
|
hasMediaBackups.returns(false);
|
|
await addJobs(
|
|
1,
|
|
{
|
|
source: AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA,
|
|
},
|
|
{
|
|
uploadTimestamp: Date.now() - 4 * MONTH,
|
|
localBackupPath: 'localBackupPath',
|
|
localKey: toBase64(generateAttachmentKeys()),
|
|
}
|
|
);
|
|
|
|
const savedJobs = await DataWriter.getNextAttachmentDownloadJobs({
|
|
limit: 100,
|
|
});
|
|
assert.strictEqual(savedJobs.length, 1);
|
|
});
|
|
it('will fallback to sentAt if uploadTimestamp is falsy', async () => {
|
|
hasMediaBackups.returns(false);
|
|
await addJobs(
|
|
1,
|
|
{
|
|
source: AttachmentDownloadSource.BACKUP_IMPORT_NO_MEDIA,
|
|
sentAt: Date.now() - 4 * MONTH,
|
|
},
|
|
{ uploadTimestamp: 0 }
|
|
);
|
|
|
|
const savedJobs = await DataWriter.getNextAttachmentDownloadJobs({
|
|
limit: 100,
|
|
});
|
|
assert.strictEqual(savedJobs.length, 0);
|
|
});
|
|
});
|
|
});
|
|
describe('AttachmentDownloadManager.runDownloadAttachmentJob', () => {
|
|
let sandbox: sinon.SinonSandbox;
|
|
let cleanupAttachmentFiles: sinon.SinonStub;
|
|
let maybeDeleteAttachmentFile: sinon.SinonStub;
|
|
let deleteDownloadFile: sinon.SinonStub;
|
|
let downloadAttachment: sinon.SinonStub;
|
|
let processNewAttachment: sinon.SinonStub;
|
|
|
|
const downloadedAttachment: Awaited<
|
|
ReturnType<typeof downloadAttachmentUtil>
|
|
> = {
|
|
path: '/path/to/file',
|
|
digest: 'digest',
|
|
plaintextHash: 'plaintextHash',
|
|
localKey: 'localKey',
|
|
version: 2,
|
|
size: 128,
|
|
};
|
|
beforeEach(async () => {
|
|
await DataWriter.removeAll();
|
|
await itemStorage.user.setAciAndDeviceId(generateAci(), 1);
|
|
sandbox = sinon.createSandbox();
|
|
downloadAttachment = sandbox
|
|
.stub()
|
|
.returns(Promise.resolve(downloadedAttachment));
|
|
cleanupAttachmentFiles = sandbox.stub();
|
|
maybeDeleteAttachmentFile = sandbox.stub();
|
|
deleteDownloadFile = sandbox.stub();
|
|
|
|
processNewAttachment = sandbox.stub().callsFake(attachment => attachment);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
sandbox.restore();
|
|
});
|
|
|
|
it('will delete attachment files if attachment not found on message', async () => {
|
|
const messageId = 'messageId';
|
|
const attachment = composeAttachment();
|
|
const abortController = new AbortController();
|
|
|
|
await window.MessageCache.saveMessage(
|
|
{
|
|
id: messageId,
|
|
type: 'incoming',
|
|
sent_at: Date.now(),
|
|
timestamp: Date.now(),
|
|
received_at: Date.now(),
|
|
conversationId: 'convoId',
|
|
attachments: [attachment],
|
|
},
|
|
{
|
|
forceSave: true,
|
|
}
|
|
);
|
|
|
|
const job = composeJob({
|
|
messageId: 'messageId',
|
|
receivedAt: Date.now(),
|
|
attachmentOverrides: attachment,
|
|
});
|
|
const result = await runDownloadAttachmentJob({
|
|
job,
|
|
isLastAttempt: false,
|
|
options: {
|
|
isForCurrentlyVisibleMessage: false,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100000,
|
|
maxTextAttachmentSizeInKib: 100000,
|
|
hasMediaBackups: false,
|
|
},
|
|
dependencies: {
|
|
downloadAttachment,
|
|
maybeDeleteAttachmentFile,
|
|
cleanupAttachmentFiles,
|
|
deleteDownloadFile,
|
|
processNewAttachment,
|
|
runDownloadAttachmentJobInner: sandbox.stub().throws(
|
|
new AttachmentNotNeededForMessageError({
|
|
contentType: MIME.IMAGE_PNG,
|
|
size: 128,
|
|
path: 'main/path',
|
|
downloadPath: '/downloadPath',
|
|
thumbnail: {
|
|
contentType: MIME.IMAGE_PNG,
|
|
size: 128,
|
|
path: 'thumbnail/path',
|
|
},
|
|
})
|
|
),
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(result.status, 'finished');
|
|
assert.strictEqual(cleanupAttachmentFiles.callCount, 1);
|
|
assert.deepStrictEqual(cleanupAttachmentFiles.getCall(0).args[0], {
|
|
contentType: MIME.IMAGE_PNG,
|
|
size: 128,
|
|
path: 'main/path',
|
|
downloadPath: '/downloadPath',
|
|
thumbnail: {
|
|
contentType: MIME.IMAGE_PNG,
|
|
size: 128,
|
|
path: 'thumbnail/path',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => {
|
|
let sandbox: sinon.SinonSandbox;
|
|
let cleanupAttachmentFiles: sinon.SinonStub;
|
|
let maybeDeleteAttachmentFile: sinon.SinonStub;
|
|
let deleteDownloadFile: sinon.SinonStub;
|
|
let downloadAttachment: sinon.SinonStub<
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
any,
|
|
ReturnType<typeof downloadAttachmentUtil>
|
|
>;
|
|
let processNewAttachment: sinon.SinonStub;
|
|
const abortController = new AbortController();
|
|
|
|
const downloadedAttachment: Awaited<
|
|
ReturnType<typeof downloadAttachmentUtil>
|
|
> = {
|
|
path: '/path/to/file',
|
|
digest: 'digest',
|
|
plaintextHash: 'plaintextHash',
|
|
localKey: 'localKey',
|
|
version: 2,
|
|
size: 128,
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await DataWriter.removeAll();
|
|
MessageCache.install();
|
|
await itemStorage.user.setAciAndDeviceId(generateAci(), 1);
|
|
sandbox = sinon.createSandbox();
|
|
downloadAttachment = sandbox
|
|
.stub()
|
|
.returns(Promise.resolve(downloadedAttachment));
|
|
cleanupAttachmentFiles = sandbox.stub();
|
|
maybeDeleteAttachmentFile = sandbox.stub();
|
|
processNewAttachment = sandbox.stub().callsFake(attachment => attachment);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
sandbox.restore();
|
|
});
|
|
|
|
describe('visible message', () => {
|
|
it('will only download full-size if attachment not from backup', async () => {
|
|
const job = composeJob({
|
|
messageId: '1',
|
|
receivedAt: 1,
|
|
attachmentOverrides: {
|
|
plaintextHash: undefined,
|
|
},
|
|
});
|
|
|
|
const result = await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: true,
|
|
hasMediaBackups: true,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default);
|
|
assert.strictEqual(downloadAttachment.callCount, 1);
|
|
|
|
const downloadCallArgs = downloadAttachment.getCall(0).args[0];
|
|
assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
downloadCallArgs.options.variant,
|
|
AttachmentVariant.Default
|
|
);
|
|
});
|
|
|
|
it('will download thumbnail first if attachment is from backup', async () => {
|
|
const job = composeJob({
|
|
messageId: '1',
|
|
receivedAt: 1,
|
|
});
|
|
|
|
downloadAttachment = sandbox.stub().callsFake(({ options }) => {
|
|
if (options.variant === AttachmentVariant.ThumbnailFromBackup) {
|
|
return Promise.resolve(downloadedAttachment);
|
|
}
|
|
throw new Error('error while downloading');
|
|
});
|
|
|
|
const result = await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: true,
|
|
hasMediaBackups: true,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
|
|
strictAssert(
|
|
result.downloadedVariant === AttachmentVariant.ThumbnailFromBackup,
|
|
'downloaded thumbnail'
|
|
);
|
|
assert.deepStrictEqual(
|
|
omit(result.attachmentWithThumbnail, 'thumbnailFromBackup'),
|
|
job.attachment
|
|
);
|
|
assert.equal(
|
|
result.attachmentWithThumbnail.thumbnailFromBackup?.path,
|
|
'/path/to/file'
|
|
);
|
|
assert.strictEqual(downloadAttachment.callCount, 2);
|
|
|
|
const firstDownloadCallArgs = downloadAttachment.getCall(0).args[0];
|
|
assert.deepStrictEqual(firstDownloadCallArgs.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
firstDownloadCallArgs.options.variant,
|
|
AttachmentVariant.ThumbnailFromBackup
|
|
);
|
|
|
|
const secondDownloadCallArgs = downloadAttachment.getCall(1).args[0];
|
|
assert.deepStrictEqual(
|
|
secondDownloadCallArgs.options.variant,
|
|
AttachmentVariant.Default
|
|
);
|
|
});
|
|
|
|
it('will download full size if backup thumbnail already downloaded', async () => {
|
|
const job = composeJob({
|
|
messageId: '1',
|
|
receivedAt: 1,
|
|
attachmentOverrides: {
|
|
thumbnailFromBackup: {
|
|
path: '/path/to/thumbnail',
|
|
size: 128,
|
|
contentType: MIME.IMAGE_JPEG,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: true,
|
|
hasMediaBackups: true,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default);
|
|
assert.strictEqual(downloadAttachment.callCount, 1);
|
|
|
|
const downloadCallArgs = downloadAttachment.getCall(0).args[0];
|
|
assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
downloadCallArgs.options.variant,
|
|
AttachmentVariant.Default
|
|
);
|
|
});
|
|
|
|
it('will attempt to download full size if thumbnail fails', async () => {
|
|
downloadAttachment = sandbox.stub().callsFake(({ options }) => {
|
|
if (options.variant === AttachmentVariant.Default) {
|
|
return Promise.resolve(downloadedAttachment);
|
|
}
|
|
throw new Error('error while downloading');
|
|
});
|
|
|
|
const job = composeJob({
|
|
messageId: '1',
|
|
receivedAt: 1,
|
|
});
|
|
|
|
const result = await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: true,
|
|
hasMediaBackups: true,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default);
|
|
assert.strictEqual(downloadAttachment.callCount, 2);
|
|
|
|
const downloadCallArgs0 = downloadAttachment.getCall(0).args[0];
|
|
assert.deepStrictEqual(downloadCallArgs0.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
downloadCallArgs0.options.variant,
|
|
AttachmentVariant.ThumbnailFromBackup
|
|
);
|
|
|
|
const downloadCallArgs1 = downloadAttachment.getCall(1).args[0];
|
|
assert.deepStrictEqual(downloadCallArgs1.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
downloadCallArgs1.options.variant,
|
|
AttachmentVariant.Default
|
|
);
|
|
});
|
|
});
|
|
describe('message not visible', () => {
|
|
it('will only download full-size if message not visible', async () => {
|
|
const job = composeJob({
|
|
messageId: '1',
|
|
receivedAt: 1,
|
|
});
|
|
|
|
const result = await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: false,
|
|
hasMediaBackups: true,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default);
|
|
assert.strictEqual(downloadAttachment.callCount, 1);
|
|
|
|
const downloadCallArgs = downloadAttachment.getCall(0).args[0];
|
|
assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
downloadCallArgs.options.variant,
|
|
AttachmentVariant.Default
|
|
);
|
|
});
|
|
it('will fallback to thumbnail if main download fails and might exist on backup', async () => {
|
|
downloadAttachment = sandbox.stub().callsFake(({ options }) => {
|
|
if (options.variant === AttachmentVariant.Default) {
|
|
throw new Error('error while downloading');
|
|
}
|
|
return downloadedAttachment;
|
|
});
|
|
|
|
const job = composeJob({
|
|
messageId: '1',
|
|
receivedAt: 1,
|
|
});
|
|
|
|
const result = await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: false,
|
|
hasMediaBackups: true,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
assert.strictEqual(
|
|
result.downloadedVariant,
|
|
AttachmentVariant.ThumbnailFromBackup
|
|
);
|
|
assert.strictEqual(downloadAttachment.callCount, 2);
|
|
|
|
const downloadCallArgs0 = downloadAttachment.getCall(0).args[0];
|
|
assert.deepStrictEqual(downloadCallArgs0.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
downloadCallArgs0.options.variant,
|
|
AttachmentVariant.Default
|
|
);
|
|
|
|
const downloadCallArgs1 = downloadAttachment.getCall(1).args[0];
|
|
assert.deepStrictEqual(downloadCallArgs1.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
downloadCallArgs1.options.variant,
|
|
AttachmentVariant.ThumbnailFromBackup
|
|
);
|
|
});
|
|
|
|
it("won't fallback to thumbnail if main download fails and not on backup", async () => {
|
|
downloadAttachment = sandbox.stub().callsFake(({ options }) => {
|
|
if (options.variant === AttachmentVariant.Default) {
|
|
throw new Error('error while downloading');
|
|
}
|
|
return {
|
|
path: '/path/to/thumbnail',
|
|
plaintextHash: 'plaintextHash',
|
|
digest: 'digest',
|
|
};
|
|
});
|
|
|
|
const job = composeJob({
|
|
messageId: '1',
|
|
receivedAt: 1,
|
|
attachmentOverrides: {
|
|
plaintextHash: undefined,
|
|
},
|
|
});
|
|
|
|
await assert.isRejected(
|
|
runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: false,
|
|
hasMediaBackups: true,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
})
|
|
);
|
|
|
|
assert.strictEqual(downloadAttachment.callCount, 1);
|
|
|
|
const downloadCallArgs = downloadAttachment.getCall(0).args[0];
|
|
assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
|
|
assert.deepStrictEqual(
|
|
downloadCallArgs.options.variant,
|
|
AttachmentVariant.Default
|
|
);
|
|
});
|
|
});
|
|
describe('deduplicates attachment if one exists on disk', () => {
|
|
const existingAttachment = {
|
|
size: 128,
|
|
contentType: MIME.VIDEO_MP4,
|
|
version: 2,
|
|
width: 1123,
|
|
height: 5811,
|
|
plaintextHash: testPlaintextHash(),
|
|
path: 'existingPath',
|
|
localKey: testAttachmentLocalKey(),
|
|
thumbnail: {
|
|
path: 'existingThumbnailPath',
|
|
version: 2,
|
|
localKey: testAttachmentLocalKey(),
|
|
contentType: MIME.IMAGE_BMP,
|
|
size: 256,
|
|
},
|
|
screenshot: {
|
|
path: 'existingScreenshotPath',
|
|
version: 2,
|
|
localKey: testAttachmentLocalKey(),
|
|
contentType: MIME.IMAGE_JPEG,
|
|
size: 512,
|
|
},
|
|
thumbnailFromBackup: {
|
|
path: 'shouldbeignored',
|
|
contentType: MIME.IMAGE_JPEG,
|
|
size: 1024,
|
|
},
|
|
} as const satisfies AttachmentType;
|
|
|
|
function composeMessage(): MessageAttributesType {
|
|
return {
|
|
id: v7(),
|
|
type: 'incoming',
|
|
sent_at: Date.now(),
|
|
timestamp: Date.now(),
|
|
received_at: Date.now(),
|
|
conversationId: v7(),
|
|
};
|
|
}
|
|
const existingMessageWithDownloadedAttachment = {
|
|
...composeMessage(),
|
|
attachments: [existingAttachment],
|
|
};
|
|
|
|
const undownloadedAttachment = {
|
|
cdnKey: 'cdnKey',
|
|
cdnNumber: 3,
|
|
version: 2,
|
|
key: testAttachmentKey(),
|
|
size: 128,
|
|
digest: testAttachmentDigest(),
|
|
plaintextHash: undefined,
|
|
contentType: MIME.VIDEO_MP4,
|
|
fileName: 'new filename',
|
|
} as const;
|
|
|
|
const newMessage = {
|
|
...composeMessage(),
|
|
attachments: [undownloadedAttachment],
|
|
};
|
|
|
|
async function writeAttachmentFile(path: string) {
|
|
await ensureFile(getAbsoluteAttachmentPath(path));
|
|
}
|
|
beforeEach(async () => {
|
|
await DataWriter.saveMessages(
|
|
[existingMessageWithDownloadedAttachment, newMessage],
|
|
{
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: async () => Promise.resolve(),
|
|
}
|
|
);
|
|
});
|
|
afterEach(async () => {
|
|
await emptyDir(
|
|
getAttachmentsPath(window.SignalContext.config.userDataPath)
|
|
);
|
|
});
|
|
|
|
it('reuses existing attachment based on plaintextHash, version, and contentType', async () => {
|
|
await writeAttachmentFile('existingPath');
|
|
await writeAttachmentFile('existingThumbnailPath');
|
|
await writeAttachmentFile('existingScreenshotPath');
|
|
|
|
downloadAttachment.callsFake(async ({ attachment }) => {
|
|
return {
|
|
path: 'newlyDownloadedPath',
|
|
plaintextHash: existingAttachment.plaintextHash,
|
|
version: existingAttachment.version,
|
|
localKey: testAttachmentLocalKey(),
|
|
size: existingAttachment.size,
|
|
digest: attachment.digest,
|
|
};
|
|
});
|
|
const job = composeJob({
|
|
messageId: newMessage.id,
|
|
receivedAt: newMessage.received_at,
|
|
attachmentOverrides: undownloadedAttachment,
|
|
});
|
|
|
|
await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: false,
|
|
hasMediaBackups: false,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
|
|
// Cleans up newly downloaded path
|
|
assert.equal(maybeDeleteAttachmentFile.callCount, 1);
|
|
assert.isTrue(
|
|
maybeDeleteAttachmentFile.calledWith('newlyDownloadedPath')
|
|
);
|
|
|
|
const updatedMessage = window.MessageCache.getById(newMessage.id);
|
|
const attachment = updatedMessage?.attributes.attachments?.[0];
|
|
const propsThatShouldBeTransferred = [
|
|
'path',
|
|
'localKey',
|
|
'version',
|
|
'width',
|
|
'height',
|
|
'thumbnail.path',
|
|
'thumbnail.localKey',
|
|
'thumbnail.size',
|
|
'thumbnail.version',
|
|
'thumbnail.contentType',
|
|
'screenshot.path',
|
|
'screenshot.localKey',
|
|
'screenshot.size',
|
|
'screenshot.version',
|
|
'screenshot.contentType',
|
|
];
|
|
|
|
assert.strictEqual(attachment?.path, existingAttachment.path);
|
|
assert.strictEqual(attachment?.width, existingAttachment.width);
|
|
assert.deepStrictEqual(
|
|
pick(attachment, propsThatShouldBeTransferred),
|
|
pick(existingAttachment, propsThatShouldBeTransferred)
|
|
);
|
|
});
|
|
|
|
it('does not reuse files if contentType differs', async () => {
|
|
await writeAttachmentFile('existingPath');
|
|
await writeAttachmentFile('existingThumbnailPath');
|
|
await writeAttachmentFile('existingScreenshotPath');
|
|
|
|
downloadAttachment.callsFake(async ({ attachment }) => {
|
|
return {
|
|
path: 'newlyDownloadedPath',
|
|
plaintextHash: existingAttachment.plaintextHash,
|
|
version: existingAttachment.version,
|
|
localKey: testAttachmentLocalKey(),
|
|
size: existingAttachment.size,
|
|
digest: attachment.digest,
|
|
};
|
|
});
|
|
const job = composeJob({
|
|
messageId: newMessage.id,
|
|
receivedAt: newMessage.received_at,
|
|
attachmentOverrides: {
|
|
...undownloadedAttachment,
|
|
contentType: MIME.VIDEO_QUICKTIME,
|
|
},
|
|
});
|
|
|
|
await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: false,
|
|
hasMediaBackups: false,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
|
|
assert.equal(maybeDeleteAttachmentFile.callCount, 0);
|
|
|
|
const updatedMessage = window.MessageCache.getById(newMessage.id);
|
|
const attachment = updatedMessage?.attributes.attachments?.[0];
|
|
|
|
assert.strictEqual(attachment.contentType, MIME.VIDEO_QUICKTIME);
|
|
assert.strictEqual(attachment.path, 'newlyDownloadedPath');
|
|
});
|
|
|
|
it('does not reuse derived files if version differs', async () => {
|
|
await writeAttachmentFile('existingPath');
|
|
await writeAttachmentFile('existingThumbnailPath');
|
|
await writeAttachmentFile('existingScreenshotPath');
|
|
|
|
await DataWriter.saveMessages(
|
|
[
|
|
{
|
|
...existingMessageWithDownloadedAttachment,
|
|
attachments: [{ ...existingAttachment, version: 1 }],
|
|
},
|
|
],
|
|
{
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
}
|
|
);
|
|
|
|
downloadAttachment.callsFake(async ({ attachment }) => {
|
|
return {
|
|
path: 'newlyDownloadedPath',
|
|
plaintextHash: existingAttachment.plaintextHash,
|
|
version: 2,
|
|
localKey: testAttachmentLocalKey(),
|
|
size: existingAttachment.size,
|
|
digest: attachment.digest,
|
|
};
|
|
});
|
|
const job = composeJob({
|
|
messageId: newMessage.id,
|
|
receivedAt: newMessage.received_at,
|
|
attachmentOverrides: undownloadedAttachment,
|
|
});
|
|
|
|
await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: false,
|
|
hasMediaBackups: false,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
|
|
assert.equal(maybeDeleteAttachmentFile.callCount, 0);
|
|
|
|
const updatedMessage = window.MessageCache.getById(newMessage.id);
|
|
const attachment = updatedMessage?.attributes.attachments?.[0];
|
|
|
|
assert.strictEqual(attachment.path, 'newlyDownloadedPath');
|
|
});
|
|
|
|
it('does not reuse attachment if it does not exist on disk', async () => {
|
|
downloadAttachment.callsFake(async ({ attachment }) => {
|
|
return {
|
|
path: 'newlyDownloadedPath',
|
|
plaintextHash: existingAttachment.plaintextHash,
|
|
version: existingAttachment.version,
|
|
localKey: testAttachmentLocalKey(),
|
|
size: existingAttachment.size,
|
|
digest: attachment.digest,
|
|
};
|
|
});
|
|
const job = composeJob({
|
|
messageId: newMessage.id,
|
|
receivedAt: newMessage.received_at,
|
|
attachmentOverrides: undownloadedAttachment,
|
|
});
|
|
|
|
await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: false,
|
|
hasMediaBackups: false,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment,
|
|
},
|
|
});
|
|
|
|
// Cleans up newly downloaded path
|
|
assert.equal(maybeDeleteAttachmentFile.callCount, 0);
|
|
|
|
const updatedMessage = window.MessageCache.getById(newMessage.id);
|
|
const attachment = updatedMessage?.attributes.attachments?.[0];
|
|
assert.strictEqual(attachment?.path, 'newlyDownloadedPath');
|
|
});
|
|
|
|
it('does not reuse thumbnail if it does not exist on disk', async () => {
|
|
await writeAttachmentFile('existingPath');
|
|
downloadAttachment.callsFake(async ({ attachment }) => {
|
|
return {
|
|
path: 'newlyDownloadedPath',
|
|
plaintextHash: existingAttachment.plaintextHash,
|
|
version: existingAttachment.version,
|
|
localKey: testAttachmentLocalKey(),
|
|
size: existingAttachment.size,
|
|
digest: attachment.digest,
|
|
};
|
|
});
|
|
const job = composeJob({
|
|
messageId: newMessage.id,
|
|
receivedAt: newMessage.received_at,
|
|
attachmentOverrides: undownloadedAttachment,
|
|
});
|
|
|
|
await runDownloadAttachmentJobInner({
|
|
job,
|
|
isForCurrentlyVisibleMessage: false,
|
|
hasMediaBackups: false,
|
|
abortSignal: abortController.signal,
|
|
maxAttachmentSizeInKib: 100 * MEBIBYTE,
|
|
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
|
|
messageExpiresAt: null,
|
|
dependencies: {
|
|
cleanupAttachmentFiles,
|
|
maybeDeleteAttachmentFile,
|
|
deleteDownloadFile,
|
|
downloadAttachment,
|
|
processNewAttachment: sandbox.stub().callsFake(attachment => ({
|
|
...attachment,
|
|
thumbnail: { path: 'newThumbnailPath' },
|
|
})),
|
|
},
|
|
});
|
|
|
|
// Cleans up newly downloaded path
|
|
assert.equal(maybeDeleteAttachmentFile.callCount, 1);
|
|
|
|
const updatedMessage = window.MessageCache.getById(newMessage.id);
|
|
const attachment = updatedMessage?.attributes.attachments?.[0];
|
|
assert.strictEqual(attachment?.path, 'existingPath');
|
|
assert.strictEqual(attachment?.thumbnail?.path, 'newThumbnailPath');
|
|
});
|
|
});
|
|
});
|