diff --git a/package-lock.json b/package-lock.json index 2d222d8c3cba3..ec668feae169a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^5.0.3", - "expensify-common": "2.0.169", + "expensify-common": "2.0.171", "expo": "54.0.10", "expo-asset": "12.0.8", "expo-audio": "1.1.1", @@ -19491,6 +19491,8 @@ }, "node_modules/classnames": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz", + "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA==", "license": "MIT", "workspaces": [ "benchmarks" @@ -19682,6 +19684,8 @@ }, "node_modules/clipboard": { "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", "license": "MIT", "dependencies": { "good-listener": "^1.2.2", @@ -21588,6 +21592,8 @@ }, "node_modules/delegate": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", "license": "MIT" }, "node_modules/depd": { @@ -23447,9 +23453,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.169", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.169.tgz", - "integrity": "sha512-0AimE2f+0vR0fGyCRaffqlaNOUQnlZFuDzoi6n7Pl4Hrr9WoRltYZwM9gSKnV9yv1K3uV7xjZodiHT0yuQFvqw==", + "version": "2.0.171", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.171.tgz", + "integrity": "sha512-2Pvnm+VNOV0v2+5Q2NRiOQiTgStZLI4r5f8XCccg3k2fz+kgKERibAUaA3FRWZavmu1/Fydekqs35q+bklFRdA==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", @@ -23458,7 +23464,7 @@ "html-entities": "^2.5.2", "jquery": "3.6.0", "localforage": "^1.10.0", - "lodash": "4.17.21", + "lodash": "4.17.23", "prop-types": "15.8.1", "punycode": "^2.3.1", "react": "16.12.0", @@ -23469,7 +23475,9 @@ } }, "node_modules/expensify-common/node_modules/semver": { - "version": "7.6.3", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -23479,7 +23487,9 @@ } }, "node_modules/expensify-common/node_modules/ua-parser-js": { - "version": "1.0.39", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", "funding": [ { "type": "opencollective", @@ -25688,6 +25698,8 @@ }, "node_modules/good-listener": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", "license": "MIT", "dependencies": { "delegate": "^3.1.2" @@ -29520,6 +29532,8 @@ }, "node_modules/jquery": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", + "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", "license": "MIT" }, "node_modules/js-base64": { @@ -29908,6 +29922,8 @@ }, "node_modules/lie": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", "license": "MIT", "dependencies": { "immediate": "~3.0.5" @@ -30190,6 +30206,8 @@ }, "node_modules/localforage": { "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", "license": "Apache-2.0", "dependencies": { "lie": "3.1.1" @@ -30209,7 +30227,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { @@ -35418,6 +35438,8 @@ }, "node_modules/select": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", "license": "MIT" }, "node_modules/select-hose": { diff --git a/package.json b/package.json index 1fc4cc534e498..ad214320fb70b 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^5.0.3", - "expensify-common": "2.0.169", + "expensify-common": "2.0.171", "expo": "54.0.10", "expo-asset": "12.0.8", "expo-audio": "1.1.1", diff --git a/src/libs/CurrentUserStore.ts b/src/libs/CurrentUserStore.ts new file mode 100644 index 0000000000000..8df4081552608 --- /dev/null +++ b/src/libs/CurrentUserStore.ts @@ -0,0 +1,24 @@ +/** + * Thin store for current user email that has no dependencies on Log. + * This avoids circular dependency: Log -> NetworkStore -> Log + * Other modules can import getCurrentUserEmail from NetworkStore for convenience, + * but Log specifically imports from here to break the cycle. + */ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +let currentUserEmail: string | null = null; + +Onyx.connectWithoutView({ + key: ONYXKEYS.SESSION, + callback: (val) => { + currentUserEmail = val?.email ?? null; + }, +}); + +function getCurrentUserEmail(): string | null { + return currentUserEmail; +} + +// eslint-disable-next-line import/prefer-default-export +export {getCurrentUserEmail}; diff --git a/src/libs/Log.ts b/src/libs/Log.ts index 41d35841eecb3..3635007380f25 100644 --- a/src/libs/Log.ts +++ b/src/libs/Log.ts @@ -11,6 +11,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import pkg from '../../package.json'; import {addLog, flushAllLogsOnAppLaunch} from './actions/Console'; import {shouldAttachLog} from './Console'; +import {getCurrentUserEmail} from './CurrentUserStore'; import getPlatform from './getPlatform'; import {post} from './Network'; import requireParameters from './requireParameters'; @@ -46,24 +47,80 @@ function LogCommand(parameters: LogCommandParameters): Promise<{requestID: strin // eslint-disable-next-line type ServerLoggingCallbackOptions = {api_setCookie: boolean; logPacket: string}; -type RequestParams = Merge; +type RequestParams = Merge< + ServerLoggingCallbackOptions, + {shouldProcessImmediately: boolean; shouldRetry: boolean; expensifyCashAppVersion: string; parameters?: string; email?: string | null} +>; + +type LogLine = {email?: string | null; [key: string]: unknown}; /** * Network interface for logger. + * Splits log packets by email to ensure logs are attributed to the correct user, + * even when multiple users' logs are queued before flushing. */ function serverLoggingCallback(logger: Logger, params: ServerLoggingCallbackOptions): Promise<{requestID: string}> { - const requestParams = params as RequestParams; - requestParams.shouldProcessImmediately = false; - requestParams.shouldRetry = false; - requestParams.expensifyCashAppVersion = `expensifyCash[${getPlatform()}]${pkg.version}`; - if (requestParams.parameters) { - requestParams.parameters = JSON.stringify(requestParams.parameters); + const baseParams = { + shouldProcessImmediately: false, + shouldRetry: false, + expensifyCashAppVersion: `expensifyCash[${getPlatform()}]${pkg.version}`, + }; + + // Parse log lines and group by email to handle multi-user scenarios + // (e.g., user signs out and another signs in before logs flush) + const logLines = JSON.parse(params.logPacket) as LogLine[]; + const logsByEmail = new Map(); + for (const line of logLines) { + const email = line.email ?? null; + const existing = logsByEmail.get(email) ?? []; + existing.push(line); + logsByEmail.set(email, existing); + } + + // Create a request for each email group + const requests: Array> = []; + for (const [email, lines] of logsByEmail) { + const requestParams: RequestParams = { + ...params, + ...baseParams, + logPacket: JSON.stringify(lines), + email, + }; + + if (requestParams.parameters) { + requestParams.parameters = JSON.stringify(requestParams.parameters); + } + + requests.push(LogCommand(requestParams)); } + // Mirror backend log payload into Telemetry logger for better context - forwardLogsToSentry(requestParams.logPacket); + forwardLogsToSentry(params.logPacket); clearTimeout(timeout); timeout = setTimeout(() => logger.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000); - return LogCommand(requestParams); + + // Use allSettled to handle partial failures gracefully. + // If we used Promise.all, a single failed group would reject and cause the Logger + // to retry the entire original packet, duplicating already-uploaded groups. + // With allSettled: if ANY succeed we resolve (preventing duplicates), only rejecting + // if ALL fail (allowing the Logger to retry). This trades potential log loss on + // partial failure for guaranteed no duplicates. + return Promise.allSettled(requests).then((results) => { + const fulfilled = results.filter((r): r is PromiseFulfilledResult<{requestID: string}> => r.status === 'fulfilled'); + const rejected = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); + + if (fulfilled.length > 0) { + // At least one group succeeded - resolve to prevent retry/duplicates + if (rejected.length > 0) { + // Log warning about lost logs (rare: partial failure + multiple email groups) + console.error(`[Log] ${rejected.length} of ${results.length} log groups failed to upload and will not be retried`); + } + return fulfilled.at(0)?.value ?? {requestID: ''}; + } + + // All requests failed - reject so Logger can retry the whole batch + throw rejected.at(0)?.reason ?? new Error('All log requests failed'); + }); } // Note: We are importing Logger from expensify-common because it is used by other platforms. The server and client logging @@ -84,6 +141,7 @@ const Log = new Logger({ }, maxLogLinesBeforeFlush: 150, isDebug: true, + getContextEmail: getCurrentUserEmail, }); timeout = setTimeout(() => Log.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000); diff --git a/src/libs/Network/NetworkStore.ts b/src/libs/Network/NetworkStore.ts index 6e168b0beacd8..491b9d6ae2aca 100644 --- a/src/libs/Network/NetworkStore.ts +++ b/src/libs/Network/NetworkStore.ts @@ -1,6 +1,7 @@ import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; +import {getCurrentUserEmail} from '@src/libs/CurrentUserStore'; import Log from '@src/libs/Log'; import ONYXKEYS from '@src/ONYXKEYS'; import type Credentials from '@src/types/onyx/Credentials'; @@ -9,7 +10,6 @@ let credentials: Credentials | null | undefined; let lastShortAuthToken: string | null | undefined; let authToken: string | null | undefined; let authTokenType: ValueOf | null; -let currentUserEmail: string | null = null; let offline = false; let authenticating = false; @@ -56,7 +56,6 @@ Onyx.connectWithoutView({ callback: (val) => { authToken = val?.authToken ?? null; authTokenType = val?.authTokenType ?? null; - currentUserEmail = val?.email ?? null; checkRequiredData(); }, }); @@ -118,10 +117,6 @@ function setAuthToken(newAuthToken: string | null) { authToken = newAuthToken; } -function getCurrentUserEmail(): string | null { - return currentUserEmail; -} - function hasReadRequiredDataFromStorage(): Promise { return isReadyPromise; } diff --git a/tests/unit/LogTest.ts b/tests/unit/LogTest.ts new file mode 100644 index 0000000000000..1a505a662b809 --- /dev/null +++ b/tests/unit/LogTest.ts @@ -0,0 +1,343 @@ +/** + * Tests for the Log module, including verification that logs correctly + * include user context when sent to the server. + */ +import MockedOnyx from 'react-native-onyx'; +import HttpUtils from '@src/libs/HttpUtils'; +import Log from '@src/libs/Log'; +import * as Network from '@src/libs/Network'; +import * as MainQueue from '@src/libs/Network/MainQueue'; +import * as NetworkStore from '@src/libs/Network/NetworkStore'; +import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type ReactNativeOnyxMock from '../../__mocks__/react-native-onyx'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const Onyx = MockedOnyx as typeof ReactNativeOnyxMock; + +// We need to NOT mock Log so we can test its actual behavior +jest.unmock('@src/libs/Log'); + +Onyx.init({ + keys: ONYXKEYS, +}); + +/** + * Helper to process the network queue and wait for updates. + * Log commands have shouldProcessImmediately=false, so we need to manually trigger processing. + */ +async function processNetworkQueue() { + MainQueue.process(); + await waitForBatchedUpdates(); +} + +type LogLine = { + message: string; + parameters: unknown; + timestamp: string; +}; + +type CapturedLogRequest = { + email: string | null | undefined; + logPacket: string | undefined; +}; + +function parseLogPacket(logPacket: string | undefined): LogLine[] { + if (!logPacket) { + return []; + } + return JSON.parse(logPacket) as LogLine[]; +} + +/** + * Sets up a mock for HttpUtils.xhr that captures all Log command requests. + * Returns an array that will be populated with captured requests when the mock is called. + */ +function mockHttpUtilsXhr(): CapturedLogRequest[] { + const capturedRequests: CapturedLogRequest[] = []; + + HttpUtils.xhr = jest.fn().mockImplementation((command: string, data: Record) => { + if (command === 'Log') { + capturedRequests.push({ + email: data.email as string | null | undefined, + logPacket: data.logPacket as string | undefined, + }); + } + return Promise.resolve({jsonCode: 200, requestID: '123'}); + }); + + return capturedRequests; +} + +describe('LogTest', () => { + const TEST_USER_EMAIL = 'test@testguy.com'; + const TEST_USER_ACCOUNT_ID = 1; + const originalXhr = HttpUtils.xhr; + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + HttpUtils.xhr = originalXhr; + MainQueue.clear(); + HttpUtils.cancelPendingRequests(); + NetworkStore.checkRequiredData(); + NetworkStore.setIsAuthenticating(false); + Network.clearProcessQueueInterval(); + SequentialQueue.resetQueue(); + + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterEach(() => { + NetworkStore.resetHasReadRequiredDataFromStorage(); + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + test('logs include user email when sent while user is signed in', async () => { + // Given a signed-in user + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_EMAIL); + await waitForBatchedUpdates(); + expect(NetworkStore.getCurrentUserEmail()).toBe(TEST_USER_EMAIL); + + const capturedRequests = mockHttpUtilsXhr(); + + // When we log a message and force it to be sent immediately + Log.info('Test log message while signed in', true); + await waitForBatchedUpdates(); + await processNetworkQueue(); + + // Then a log request should have been made + expect(capturedRequests.length).toBeGreaterThanOrEqual(1); + + // And the request for this user's logs should include their email + const userRequest = capturedRequests.find((req) => req.email === TEST_USER_EMAIL); + expect(userRequest).toBeDefined(); + }); + + test('logs queued while signed in retain user email after session is cleared', async () => { + // Given a signed-in user + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_EMAIL); + await waitForBatchedUpdates(); + expect(NetworkStore.getCurrentUserEmail()).toBe(TEST_USER_EMAIL); + + // When multiple log messages are queued + Log.info('User performed action A'); + Log.info('User performed action B'); + Log.info('User performed action C'); + Log.hmmm('Something suspicious happened'); + + // And then Onyx is cleared (sign out) + await Onyx.clear(); + await waitForBatchedUpdates(); + expect(NetworkStore.getCurrentUserEmail()).toBeNull(); + + const capturedRequests = mockHttpUtilsXhr(); + + // And then logs are flushed (this log has null email since user is signed out) + Log.info('Final trigger to flush', true); + await waitForBatchedUpdates(); + await processNetworkQueue(); + + // Then multiple requests should have been made (split by email) + expect(capturedRequests.length).toBeGreaterThanOrEqual(1); + + // And a request with the original user's email should contain their logs + const userRequest = capturedRequests.find((req) => req.email === TEST_USER_EMAIL); + expect(userRequest).toBeDefined(); + expect(userRequest?.logPacket).toBeDefined(); + + const userLogs = parseLogPacket(userRequest?.logPacket); + expect(userLogs.length).toBeGreaterThanOrEqual(4); + + const messages = userLogs.map((log) => log.message); + expect(messages).toEqual( + expect.arrayContaining([ + expect.stringContaining('User performed action A'), + expect.stringContaining('User performed action B'), + expect.stringContaining('User performed action C'), + expect.stringContaining('Something suspicious happened'), + ]), + ); + }); + + test('logs during reauthentication flow retain user context', async () => { + // This replicates the scenario from Authentication.ts where logs are + // created during reauthentication but sent after Onyx is cleared + + // Given a signed-in user + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_EMAIL); + await waitForBatchedUpdates(); + expect(NetworkStore.getCurrentUserEmail()).toBe(TEST_USER_EMAIL); + + // When a log is created (simulating Authentication.ts line 135) + Log.info('Reauthenticate - No credentials available, redirecting to sign in'); + + // And then Onyx is cleared (simulating redirectToSignIn) + await Onyx.clear(); + await waitForBatchedUpdates(); + expect(NetworkStore.getCurrentUserEmail()).toBeNull(); + + const capturedRequests = mockHttpUtilsXhr(); + + // And then logs are flushed (this log has null email since user is signed out) + Log.info('Trigger flush', true); + await waitForBatchedUpdates(); + await processNetworkQueue(); + + // Then a request with the original user's email should be made + const userRequest = capturedRequests.find((req) => req.email === TEST_USER_EMAIL); + expect(userRequest).toBeDefined(); + + // And the log about "No credentials" should be in that user's packet + const userLogs = parseLogPacket(userRequest?.logPacket); + const messages = userLogs.map((log) => log.message); + expect(messages).toEqual(expect.arrayContaining([expect.stringContaining('No credentials available, redirecting to sign in')])); + }); + + test('logs from multiple users in same flush are split into separate requests', async () => { + // This tests the scenario where user A signs out and user B signs in + // before the queued logs flush + + const USER_A_EMAIL = 'userA@test.com'; + const USER_B_EMAIL = 'userB@test.com'; + + // Given user A is signed in + await TestHelper.signInWithTestUser(1, USER_A_EMAIL); + await waitForBatchedUpdates(); + expect(NetworkStore.getCurrentUserEmail()).toBe(USER_A_EMAIL); + + // Set up mock to capture all requests + const capturedRequests = mockHttpUtilsXhr(); + + // And user A creates some logs + Log.info('User A action 1'); + Log.info('User A action 2'); + + // When user A signs out (using merge to avoid triggering extra logs) + await Onyx.merge(ONYXKEYS.SESSION, {email: null, authToken: null}); + await waitForBatchedUpdates(); + expect(NetworkStore.getCurrentUserEmail()).toBeNull(); + + // And user B "signs in" (just set the email directly to avoid system logs) + await Onyx.merge(ONYXKEYS.SESSION, {email: USER_B_EMAIL, authToken: 'token123'}); + await waitForBatchedUpdates(); + expect(NetworkStore.getCurrentUserEmail()).toBe(USER_B_EMAIL); + + // And user B creates some logs + Log.info('User B action 1'); + Log.info('User B action 2'); + + // And then all logs are flushed + Log.info('Trigger flush', true); + await waitForBatchedUpdates(); + await processNetworkQueue(); + + // Helper to collect all messages from all requests for a given email + const getAllMessagesForEmail = (email: string): string[] => { + return capturedRequests + .filter((req) => req.email === email) + .flatMap((req) => parseLogPacket(req.logPacket)) + .map((log) => log.message); + }; + + // Then requests should have been made for each user + const userAMessages = getAllMessagesForEmail(USER_A_EMAIL); + const userBMessages = getAllMessagesForEmail(USER_B_EMAIL); + + expect(userAMessages.length).toBeGreaterThan(0); + expect(userBMessages.length).toBeGreaterThan(0); + + // And user A's explicit logs should be in requests with their email + expect(userAMessages).toEqual(expect.arrayContaining([expect.stringContaining('User A action 1'), expect.stringContaining('User A action 2')])); + // User B's explicit logs should NOT be in user A's requests + expect(userAMessages.join()).not.toContain('User B action'); + + // And user B's explicit logs should be in requests with their email + expect(userBMessages).toEqual(expect.arrayContaining([expect.stringContaining('User B action 1'), expect.stringContaining('User B action 2')])); + // User A's explicit logs should NOT be in user B's requests + expect(userBMessages.join()).not.toContain('User A action'); + }); + + test('partial upload failure does not cause duplicates on retry', async () => { + // This tests that if one email group fails while others succeed, + // we resolve (to prevent retry/duplicates) rather than reject + + const USER_A_EMAIL = 'userA@test.com'; + const USER_B_EMAIL = 'userB@test.com'; + + // Given user A is signed in + await TestHelper.signInWithTestUser(1, USER_A_EMAIL); + await waitForBatchedUpdates(); + + // Set up mock that fails for USER_A but succeeds for others + const capturedRequests: CapturedLogRequest[] = []; + HttpUtils.xhr = jest.fn().mockImplementation((command: string, data: Record) => { + if (command === 'Log') { + capturedRequests.push({ + email: data.email as string | null | undefined, + logPacket: data.logPacket as string | undefined, + }); + + // Fail requests for USER_A_EMAIL + if (data.email === USER_A_EMAIL) { + return Promise.reject(new Error('Simulated network failure')); + } + } + return Promise.resolve({jsonCode: 200, requestID: '123'}); + }); + + // User A creates logs + Log.info('User A log'); + + // Switch to user B + await Onyx.merge(ONYXKEYS.SESSION, {email: USER_B_EMAIL, authToken: 'token123'}); + await waitForBatchedUpdates(); + + // User B creates logs + Log.info('User B log'); + + // Flush logs - this should NOT throw even though USER_A's request failed + Log.info('Trigger flush', true); + await waitForBatchedUpdates(); + + // The network queue should process without throwing + await expect(processNetworkQueue()).resolves.not.toThrow(); + + // Both requests should have been attempted + expect(capturedRequests.some((req) => req.email === USER_A_EMAIL)).toBe(true); + expect(capturedRequests.some((req) => req.email === USER_B_EMAIL)).toBe(true); + }); + + test('all requests failing causes rejection for retry', async () => { + // This tests that if ALL email groups fail, we reject so the Logger can retry + + // Given a signed-in user + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_EMAIL); + await waitForBatchedUpdates(); + + // Set up mock that always fails for Log commands + let logCallCount = 0; + HttpUtils.xhr = jest.fn().mockImplementation((command: string) => { + if (command === 'Log') { + logCallCount++; + return Promise.reject(new Error('Simulated network failure')); + } + return Promise.resolve({jsonCode: 200, requestID: '123'}); + }); + + // Create a log + Log.info('Test log'); + + // Flush logs + Log.info('Trigger flush', true); + await waitForBatchedUpdates(); + + // The network queue processing should eventually reject (though the queue handles this internally) + await processNetworkQueue(); + + // Verify Log command was attempted + expect(logCallCount).toBeGreaterThan(0); + }); +});