Files
Desktop/ts/test-electron/services/ReleaseNoteAndMegaphoneFetcher_test.preload.ts

644 lines
19 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { EventEmitter } from 'node:events';
import { v4 as uuid } from 'uuid';
import { ReleaseNoteAndMegaphoneFetcher } from '../../services/releaseNoteAndMegaphoneFetcher.preload.js';
import * as durations from '../../util/durations/index.std.js';
import { generateAci } from '../../types/ServiceId.std.js';
import { saveNewMessageBatcher } from '../../util/messageBatcher.preload.js';
import type { CIType } from '../../CI.preload.js';
import type { ConversationModel } from '../../models/conversations.preload.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import { DataReader, DataWriter } from '../../sql/Client.preload.js';
import type { RemoteMegaphoneId } from '../../types/Megaphone.std.js';
const { getAllMegaphones, hasMegaphone } = DataReader;
const waitUntil = (
condition: () => boolean,
timeoutMs = 5000
): Promise<void> => {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const intervalMs = 10;
const intervalId = setInterval(() => {
if (condition()) {
clearInterval(intervalId);
resolve();
} else if (Date.now() - startTime > timeoutMs) {
clearInterval(intervalId);
reject(new Error('waitUntil timeout'));
}
}, intervalMs);
});
};
const testMegaphone = {
id: 'banana' as RemoteMegaphoneId,
desktopMinVersion: '1.0.0',
priority: 1,
dontShowBeforeEpochMs: 0,
dontShowAfterEpochMs: Date.now() + 9001,
showForNumberOfDays: 7,
primaryCtaId: null,
secondaryCtaId: null,
primaryCtaData: null,
secondaryCtaData: null,
conditionalId: null,
title: 'a',
body: 'b',
primaryCtaText: null,
secondaryCtaText: null,
imagePath: 'megaphone0',
localeFetched: 'en',
shownAt: null,
snoozedAt: null,
snoozeCount: 0,
isFinished: false,
} as const;
describe('ReleaseNoteAndMegaphoneFetcher', () => {
const NEXT_FETCH_TIME_STORAGE_KEY = 'releaseNotesNextFetchTime';
const PREVIOUS_MANIFEST_HASH_STORAGE_KEY = 'releaseNotesPreviousManifestHash';
const VERSION_WATERMARK_STORAGE_KEY = 'releaseNotesVersionWatermark';
const FETCH_INTERVAL = 3 * durations.DAY;
type TestSetupOptions = {
// Storage values
storedVersionWatermark?: string;
storedPreviousManifestHash?: string;
storedNextFetchTime?: number;
// Version configuration
currentVersion?: string;
noteVersion?: string;
isNewVersion?: boolean;
// Server responses
isOnline?: boolean;
manifestHash?: string;
manifestAnnouncements?: Array<{
uuid: string;
desktopMinVersion: string;
ctaId: string;
link: string;
}>;
manifestMegaphones?: Array<{
uuid: string;
priority: number;
desktopMinVersion: string;
dontShowBeforeEpochSeconds: number;
dontShowAfterEpochSeconds: number;
showForNumberOfDays: number;
conditionalId: string;
primaryCtaId: string;
secondaryCtaId: string;
secondaryCtaData: object;
}>;
megaphone?: {
uuid: string;
title: string;
body: string;
image?: string;
primaryCtaText?: string;
secondaryCtaText?: string;
};
releaseNote?: {
uuid: string;
title: string;
body: string;
bodyRanges: Array<{ start: number; length: number; style: string }>;
};
// Conversation behavior
conversationIsBlocked?: boolean;
// Timing
now?: number;
};
let sandbox: sinon.SinonSandbox;
let clock: sinon.SinonFakeTimers | undefined;
let originalSignalCI: CIType | undefined;
let fakeMegaphoneUuid: string;
async function setupTest(options: TestSetupOptions = {}) {
sandbox = sinon.createSandbox();
// Reset conversation controller for clean state
window.ConversationController.reset();
await window.ConversationController.load();
const {
storedVersionWatermark = '1.36.0',
storedPreviousManifestHash,
storedNextFetchTime,
currentVersion = '1.36.0',
noteVersion = '1.37.0',
isNewVersion = false,
isOnline = true,
manifestHash = 'abc123',
manifestAnnouncements,
manifestMegaphones,
megaphone,
releaseNote,
conversationIsBlocked = false,
now = 1621500000000,
} = options;
const events = new EventEmitter();
const fakeNoteUuid = uuid();
fakeMegaphoneUuid = uuid();
// Create fake conversation
const fakeConversation = {
isBlocked: sandbox.stub().returns(conversationIsBlocked),
onNewMessage: sandbox.stub().resolves(),
getServiceId: sandbox.stub().returns(generateAci()),
set: sandbox.stub(),
throttledUpdateUnread: sandbox.stub(),
id: 'fake-signal-conversation-id',
};
// Stub global methods
sandbox
.stub(window.ConversationController, 'getOrCreateSignalConversation')
.resolves(fakeConversation as unknown as ConversationModel);
sandbox.stub(window.MessageCache, 'register').callsFake(message => message);
// Save original values before modifying
originalSignalCI = window.SignalCI;
// Stub server methods
const serverStubs = {
isOnline: sandbox.stub().returns(isOnline),
getMegaphone: sandbox.stub().resolves(
megaphone || {
uuid: fakeMegaphoneUuid,
title: 'megaphone',
body: 'cats',
image: 'https://signal.org/axolotl.png',
primaryCtaText: 'donate',
secondaryCtaText: 'snooze',
}
),
getReleaseNotesManifestHash: sandbox.stub().resolves(manifestHash),
getReleaseNotesManifest: sandbox.stub().resolves({
announcements: manifestAnnouncements || [
{
uuid: fakeNoteUuid,
desktopMinVersion: noteVersion,
ctaId: 'test-cta',
link: 'https://signal.org',
},
],
megaphones: manifestMegaphones || [
{
uuid: fakeMegaphoneUuid,
priority: 100,
desktopMinVersion: noteVersion,
dontShowBeforeEpochSeconds: Math.floor(
(Date.now() - 1 * durations.DAY) / 1000
),
dontShowAfterEpochSeconds: Math.floor(
(Date.now() + 30 * durations.DAY) / 1000
),
showForNumberOfDays: 30,
conditionalId: 'standard_donate',
primaryCtaId: 'donate',
secondaryCtaId: 'snooze',
secondaryCtaData: { snoozeDurationDays: [5, 7, 100] },
},
],
}),
getReleaseNoteHash: sandbox.stub().resolves('note-hash-1'),
getReleaseNote: sandbox.stub().resolves(
releaseNote || {
uuid: fakeNoteUuid,
title: 'New Release',
body: 'This is the body text of the release note',
bodyRanges: [{ start: 0, length: 4, style: 'bold' }],
}
),
getReleaseNoteImageAttachment: sandbox.stub().resolves({
imageData: new Uint8Array([1, 2, 3]),
contentType: 'image/png',
}),
};
// Stub other globals
sandbox.stub(window.SignalContext, 'getI18nLocale').returns('en-US');
sandbox.stub(window, 'getVersion').returns(currentVersion);
// Mock Whisper events
const fakeWhisperEvents = new EventEmitter();
sandbox.stub(window.Whisper, 'events').value(fakeWhisperEvents);
// Mock saveNewMessageBatcher
sandbox.stub(saveNewMessageBatcher, 'add').resolves();
// Mock SignalCI
window.SignalCI =
window.SignalCI ||
({
handleEvent: sandbox.stub(),
} as unknown as CIType);
// Helper to run fetcher and wait for completion
const runFetcherAndWaitForCompletion = async () => {
await ReleaseNoteAndMegaphoneFetcher.init(
serverStubs,
events,
isNewVersion
);
// Wait for SignalCI.handleEvent to be called
const signalCI = window.SignalCI as unknown as {
handleEvent: sinon.SinonStub;
};
await waitUntil(() => signalCI.handleEvent.called, 1000);
};
// Storage setup helper
const setupStorage = async () => {
// Set up storage values
await itemStorage.put('chromiumRegistrationDone', '');
if (storedVersionWatermark !== undefined) {
await itemStorage.put(
VERSION_WATERMARK_STORAGE_KEY,
storedVersionWatermark
);
} else {
await itemStorage.remove(VERSION_WATERMARK_STORAGE_KEY);
}
if (storedPreviousManifestHash !== undefined) {
await itemStorage.put(
PREVIOUS_MANIFEST_HASH_STORAGE_KEY,
storedPreviousManifestHash
);
} else {
await itemStorage.remove(PREVIOUS_MANIFEST_HASH_STORAGE_KEY);
}
if (storedNextFetchTime !== undefined) {
await itemStorage.put(NEXT_FETCH_TIME_STORAGE_KEY, storedNextFetchTime);
} else {
await itemStorage.remove(NEXT_FETCH_TIME_STORAGE_KEY);
}
};
// Helper functions to get current storage values
const getCurrentHash = () => {
return itemStorage.get(PREVIOUS_MANIFEST_HASH_STORAGE_KEY);
};
const getCurrentWatermark = () => {
return itemStorage.get(VERSION_WATERMARK_STORAGE_KEY);
};
return {
// Core objects
sandbox,
clock,
events,
fakeConversation,
serverStubs,
// Constants
NEXT_FETCH_TIME_STORAGE_KEY,
PREVIOUS_MANIFEST_HASH_STORAGE_KEY,
VERSION_WATERMARK_STORAGE_KEY,
FETCH_INTERVAL,
// Test data
fakeNoteUuid,
now,
// Helper functions
runFetcherAndWaitForCompletion,
setupStorage,
getCurrentHash,
getCurrentWatermark,
};
}
beforeEach(async () => {
await DataWriter.removeAll();
await itemStorage.user.setAciAndDeviceId(generateAci(), 1);
await itemStorage.user.setNumber('+14155550111');
});
afterEach(async () => {
// Reset static state
ReleaseNoteAndMegaphoneFetcher.initComplete = false;
// Restore all stubs and timers
sandbox.restore();
sandbox.reset();
clock?.restore();
// Restore original global values (even if they were undefined)
window.SignalCI = originalSignalCI as CIType;
// Reset storage state
await itemStorage.fetch();
// Reset conversation controller for next test
window.ConversationController.reset();
});
describe('#run', () => {
it('initializes version watermark if not set', async () => {
const {
setupStorage,
runFetcherAndWaitForCompletion,
getCurrentWatermark,
} = await setupTest({
storedVersionWatermark: undefined,
isNewVersion: true,
});
await setupStorage();
await runFetcherAndWaitForCompletion();
const storedWatermark = getCurrentWatermark();
assert.strictEqual(storedWatermark, '1.36.0');
});
it('fetches manifest when hash changes', async () => {
const {
setupStorage,
runFetcherAndWaitForCompletion,
serverStubs,
getCurrentHash,
} = await setupTest({
storedPreviousManifestHash: 'old-hash',
manifestHash: 'new-hash-123',
isNewVersion: true,
});
await setupStorage();
await runFetcherAndWaitForCompletion();
sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);
assert.strictEqual(getCurrentHash(), 'new-hash-123');
});
// TODO(DESKTOP-9092): test setup requires isNewVersion=true, but
// that flag forces a manifest fetch.
it.skip('does not fetch when hash is the same', async () => {
const {
setupStorage,
runFetcherAndWaitForCompletion,
serverStubs,
getCurrentHash,
} = await setupTest({
storedPreviousManifestHash: 'hash',
manifestHash: 'hash',
isNewVersion: true,
});
await setupStorage();
await runFetcherAndWaitForCompletion();
sinon.assert.notCalled(serverStubs.getReleaseNotesManifest);
assert.strictEqual(getCurrentHash(), 'hash');
});
it('forces a manifest fetch for a new version', async () => {
const {
setupStorage,
runFetcherAndWaitForCompletion,
serverStubs,
getCurrentHash,
} = await setupTest({
storedPreviousManifestHash: 'hash',
manifestHash: 'hash',
isNewVersion: true,
});
await setupStorage();
await runFetcherAndWaitForCompletion();
sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);
assert.strictEqual(getCurrentHash(), 'hash');
});
it('processes release notes when valid notes are found and updates watermark', async () => {
const {
setupStorage,
runFetcherAndWaitForCompletion,
serverStubs,
getCurrentWatermark,
} = await setupTest({
storedPreviousManifestHash: 'old-hash',
manifestHash: 'new-hash-123',
currentVersion: 'v1.37.0',
noteVersion: 'v1.37.0',
storedVersionWatermark: 'v1.36.0',
isNewVersion: true,
});
await setupStorage();
await runFetcherAndWaitForCompletion();
sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);
sinon.assert.calledOnce(serverStubs.getReleaseNote);
sinon.assert.called(window.MessageCache.register as sinon.SinonStub);
assert.strictEqual(getCurrentWatermark(), 'v1.37.0');
});
it('processes megaphones', async () => {
const myMegaphone = {
uuid: uuid(),
priority: 100,
desktopMinVersion: 'v1.37.0',
dontShowBeforeEpochSeconds: Math.floor(
(Date.now() - 1 * durations.DAY) / 1000
),
dontShowAfterEpochSeconds: Math.floor(
(Date.now() + 30 * durations.DAY) / 1000
),
showForNumberOfDays: 30,
conditionalId: 'standard_donate',
primaryCtaId: 'donate',
secondaryCtaId: 'snooze',
secondaryCtaData: { snoozeDurationDays: [5, 7, 100] },
};
const manifestMegaphones = [myMegaphone];
const { setupStorage, runFetcherAndWaitForCompletion, serverStubs } =
await setupTest({
storedPreviousManifestHash: 'old-hash',
manifestHash: 'new-hash-123',
currentVersion: 'v1.37.0',
noteVersion: 'v1.37.0',
storedVersionWatermark: 'v1.36.0',
isNewVersion: true,
manifestMegaphones,
megaphone: {
uuid: myMegaphone.uuid,
title: 'megaphone',
body: 'cats',
image: 'https://signal.org/axolotl.png',
primaryCtaText: 'donate',
secondaryCtaText: 'snooze',
},
});
await setupStorage();
await runFetcherAndWaitForCompletion();
sinon.assert.calledOnce(serverStubs.getMegaphone);
sinon.assert.calledOnce(serverStubs.getReleaseNoteImageAttachment);
const dbMegaphones = await getAllMegaphones();
const dbMegaphone = dbMegaphones[0];
assert.strictEqual(dbMegaphones.length, 1);
assert.strictEqual(dbMegaphone.id, myMegaphone.uuid);
assert.strictEqual(
dbMegaphone.dontShowBeforeEpochMs,
myMegaphone.dontShowBeforeEpochSeconds * 1000
);
assert.strictEqual(
dbMegaphone.dontShowAfterEpochMs,
myMegaphone.dontShowAfterEpochSeconds * 1000
);
});
it('only processes megaphones with matching countries value', async () => {
const baseMegaphone = {
priority: 100,
desktopMinVersion: 'v1.37.0',
dontShowBeforeEpochSeconds: Math.floor(
(Date.now() - 1 * durations.DAY) / 1000
),
dontShowAfterEpochSeconds: Math.floor(
(Date.now() + 30 * durations.DAY) / 1000
),
showForNumberOfDays: 30,
conditionalId: 'standard_donate',
primaryCtaId: 'donate',
secondaryCtaId: 'snooze',
secondaryCtaData: { snoozeDurationDays: [5, 7, 100] },
};
const megaphoneForMyCountry = {
...baseMegaphone,
uuid: uuid(),
countries: '1:1000000',
};
const megaphoneForDifferentCountry = {
...baseMegaphone,
uuid: uuid(),
countries: '20:1000000,212:1000000,213:1000000,216:1000000',
};
const megaphoneForMyCountryBucketZeroPPM = {
...baseMegaphone,
uuid: uuid(),
countries: '1:0',
};
const wildcardMegaphone = {
...baseMegaphone,
uuid: uuid(),
countries: '*:1000000',
};
const manifestMegaphones = [
megaphoneForDifferentCountry,
megaphoneForMyCountry,
megaphoneForMyCountryBucketZeroPPM,
wildcardMegaphone,
];
const { setupStorage, runFetcherAndWaitForCompletion, serverStubs } =
await setupTest({
storedPreviousManifestHash: 'old-hash',
manifestHash: 'new-hash-123',
currentVersion: 'v1.37.0',
noteVersion: 'v1.37.0',
storedVersionWatermark: 'v1.36.0',
isNewVersion: true,
manifestMegaphones,
megaphone: {
uuid: megaphoneForMyCountry.uuid,
title: 'megaphone',
body: 'cats',
image: 'https://signal.org/axolotl.png',
primaryCtaText: 'donate',
secondaryCtaText: 'snooze',
},
});
await setupStorage();
await runFetcherAndWaitForCompletion();
sinon.assert.calledTwice(serverStubs.getMegaphone);
const dbMegaphones = await getAllMegaphones();
assert.strictEqual(dbMegaphones.length, 2);
assert.strictEqual(dbMegaphones[0].id, megaphoneForMyCountry.uuid);
assert.strictEqual(dbMegaphones[1].id, wildcardMegaphone.uuid);
});
});
it('deletes saved megaphones which were removed from the manifest', async () => {
await DataWriter.createMegaphone(testMegaphone);
const isOldMegaphonePresentBeforeRun = await hasMegaphone(testMegaphone.id);
assert.isTrue(isOldMegaphonePresentBeforeRun, 'saved during setup');
const { setupStorage, runFetcherAndWaitForCompletion } = await setupTest({
storedPreviousManifestHash: 'old-hash',
manifestHash: 'new-hash-123',
currentVersion: 'v1.37.0',
noteVersion: 'v1.37.0',
storedVersionWatermark: 'v1.36.0',
isNewVersion: true,
});
await setupStorage();
await runFetcherAndWaitForCompletion();
const isOldMegaphonePresentAfterRun = await hasMegaphone(testMegaphone.id);
assert.isFalse(isOldMegaphonePresentAfterRun, 'deleted after run');
const dbMegaphones = await getAllMegaphones();
assert.strictEqual(dbMegaphones.length, 1);
const dbMegaphone = dbMegaphones[0];
assert.strictEqual(dbMegaphone.id, fakeMegaphoneUuid);
});
it('deletes saved megaphones if the manifest has empty megaphones', async () => {
await DataWriter.createMegaphone(testMegaphone);
const isOldMegaphonePresentBeforeRun = await hasMegaphone(testMegaphone.id);
assert.isTrue(isOldMegaphonePresentBeforeRun, 'saved during setup');
const { setupStorage, runFetcherAndWaitForCompletion } = await setupTest({
storedPreviousManifestHash: 'old-hash',
manifestHash: 'new-hash-123',
currentVersion: 'v1.37.0',
noteVersion: 'v1.37.0',
storedVersionWatermark: 'v1.36.0',
isNewVersion: true,
manifestMegaphones: [],
});
await setupStorage();
await runFetcherAndWaitForCompletion();
const dbMegaphones = await getAllMegaphones();
assert.strictEqual(dbMegaphones.length, 0);
});
});