// 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'; const { getAllMegaphones } = DataReader; const waitUntil = ( condition: () => boolean, timeoutMs = 5000 ): Promise => { 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); }); }; 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; primaryCtaTest?: 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', primaryCtaTest: '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', primaryCtaTest: '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', primaryCtaTest: '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); }); }); });