Files
iNaturalistReactNative/tests/integration/MyObservations.test.js
T
Ryan Stelly 277816b0e0 MOB-1458 use local observation ids (#3763)
* only return observations from useLocalObs

* remove unnecessary var

* export unsync'd filter

* remove obsoleted numUnuploadedObservations zustand state

* tests, typo

* fix tests

* add sync fields to keypaths & depp array

* consistent naming

* handle only ids downstream

* switch over to useIds

* restore ids return

* memoize realm mapping result

* extract realm hooks mocks into factory-available helper

* reapply to myobsresults

* reapply to myobsresults

* go back to wrapped ids for list parity

* fix test

* restrict mocked prop for more accurate MyObsSimple test

* comment cleanup

* fix fake timer flake w/ corrected setup

Claude:

1. withAnimatedTimeTravelEnabled({ skipFakeTimers: true }) does NOT call jest.useFakeTimers(), but still calls jest.setSystemTime(new Date(0)) in beforeEach — which requires fake timers.
2. Both SoundRecorder.test.js and PhotoDeletionExisting.test.js (and ObsEdit.test.js) call global.timeTravel(300) inside waitFor, which also requires fake timers.
3. Tests pass flakily when fake timers accidentally leak from another test file running in the same Jest worker. When they don't leak, timeTravel throws.
4. FadeInView is fully mocked to a plain View (instant) and Reanimated uses setUpTests(), so the 300ms timeTravel isn't actually advancing any real animation — it's just broken.

The fix: guard setSystemTime with the same check, and remove the timeTravel(300) calls from waitFor in tests that use skipFakeTimers: true.

* fix remaining time travel uses

* reset / restore timers to fix leaking fake timers
2026-06-30 11:48:47 -05:00

452 lines
17 KiB
JavaScript

// These test ensure that My Observation integrates with other systems like
// remote data retrieval and local data persistence
import { fireEvent, screen, waitFor } from "@testing-library/react-native";
import { MS_BEFORE_TOOLBAR_RESET } from "components/MyObservations/hooks/useUploadObservations";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer";
import i18next from "i18next";
import inatjs from "inaturalistjs";
import flatten from "lodash/flatten";
import React from "react";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import { sleep } from "sharedHelpers/util";
import { zustandStorage } from "stores/useStore";
import factory, { makeResponse } from "tests/factory";
import faker from "tests/helpers/faker";
import { renderAppWithComponent } from "tests/helpers/render";
import setStoreStateLayout from "tests/helpers/setStoreStateLayout";
import setupUniqueRealm from "tests/helpers/uniqueRealm";
import { signIn, signOut } from "tests/helpers/user";
const mockUnsyncedObservations = [
factory( "LocalObservation", {
_synced_at: null,
observed_on_string: "2024-05-01",
latitude: 1.2345,
longitude: 1.2345,
observationPhotos: [
factory( "LocalObservationPhoto", {
photo: {
url: faker.image.url( ),
position: 0,
},
} ),
],
} ),
factory( "LocalObservation", {
_synced_at: null,
observed_on_string: "2024-05-02",
latitude: 1.2345,
longitude: 1.2345,
observationPhotos: [
factory( "LocalObservationPhoto", {
photo: {
url: `${faker.image.url( )}/100`,
position: 0,
},
} ),
factory( "LocalObservationPhoto", {
photo: {
url: `${faker.image.url( )}/200`,
position: 1,
},
} ),
],
} ),
];
const mockDeletedIds = [
faker.number.int( ),
faker.number.int( ),
];
jest.mock( "sharedHooks/useFontScale", () => ( {
__esModule: true,
default: ( ) => ( { isLargeFontScale: false } ),
} ) );
const mockSyncedObservations = [
factory( "LocalObservation", {
_synced_at: faker.date.past( ),
id: mockDeletedIds[0],
} ),
factory( "LocalObservation", {
_synced_at: faker.date.past( ),
} ),
];
const mockUser = factory( "LocalUser", {
login: faker.internet.userName( ),
iconUrl: faker.image.url( ),
locale: "en",
} );
const checkToolbarResetWithUnsyncedObs = ( ) => waitFor( ( ) => {
const toolbarText = screen.getByText( /Upload 2 observations/ );
expect( toolbarText ).toBeVisible( );
} );
const writeObservationsToRealm = ( observations, message ) => {
const realm = global.mockRealms[__filename];
safeRealmWrite( realm, ( ) => {
observations.forEach( mockObservation => {
realm.create( "Observation", mockObservation );
} );
}, message );
};
// UNIQUE REALM SETUP
const mockRealmIdentifier = __filename;
const { mockRealmModelsIndex, uniqueRealmBeforeAll, uniqueRealmAfterAll } = setupUniqueRealm(
mockRealmIdentifier,
);
jest.mock( "realmModels/index", ( ) => mockRealmModelsIndex );
jest.mock( "providers/contexts", ( ) => {
const originalModule = jest.requireActual( "providers/contexts" );
const { makeRealmHooks } = jest.requireActual( "tests/helpers/uniqueRealm" );
return {
__esModule: true,
...originalModule,
RealmContext: {
...originalModule.RealmContext,
...makeRealmHooks( __filename ),
},
};
} );
beforeAll( uniqueRealmBeforeAll );
afterAll( uniqueRealmAfterAll );
// /UNIQUE REALM SETUP
const firstObservation = mockUnsyncedObservations[0];
const secondObservation = mockUnsyncedObservations[1];
const displayItemByTestId = testId => {
const item = screen.getByTestId( testId );
expect( item ).toBeVisible( );
};
const pressItemByTestId = testId => {
const item = screen.getByTestId( testId );
expect( item ).toBeVisible( );
fireEvent.press( item );
};
const displayItemByText = text => {
const item = screen.getByText( text );
expect( item ).toBeVisible( );
};
beforeEach( ( ) => {
setStoreStateLayout( {
isDefaultMode: false,
isAllAddObsOptionsMode: true,
} );
} );
describe( "MyObservations", ( ) => {
// For some reason this interferes with the "should not make a request to
// users/me" test below, can't figure out why ~~~kueda 20230105
// TODO: this looks to me more like it should be covered by unit tests - @jtklein
// describe( "accessibility", ( ) => {
// it( "should not have accessibility errors", async ( ) => {
// const mockUser = factory( "LocalUser" );
// await signIn( mockUser );
// const observations = [factory( "RemoteObservation" )];
// inatjs.observations.search.mockResolvedValue( makeResponse( observations ) );
// renderAppWithComponent( <ObsList /> );
// const { findByTestId } = renderAppWithComponent( <ObsList /> );
// expect( await screen.findByTestId( "ObservationViews.myObservations" ) ).toBeAccessible( );
// } );
// } );
describe( "when signed out", ( ) => {
async function testApiMethodNotCalled( apiMethod ) {
// Let's make sure the mock hasn't already been used
expect( apiMethod ).not.toHaveBeenCalled( );
const realm = global.mockRealms[__filename];
const signedInUsers = realm.objects( "User" ).filtered( "signedIn == true" );
expect( signedInUsers.length ).toEqual( 0 );
renderAppWithComponent( <MyObservationsContainer /> );
const loginText = i18next.t( "Use-iNaturalist-to-identify-any-living-thing" );
expect( await screen.findByText( loginText ) ).toBeTruthy( );
// Unpleasant, but without adjusting the timeout it doesn't seem like
// all of these requests get caught
await waitFor( ( ) => {
expect( apiMethod ).not.toHaveBeenCalled( );
}, { timeout: 3000, interval: 500 } );
}
it( "should not make a request to users/me", async ( ) => {
await testApiMethodNotCalled( inatjs.users.me );
} );
it( "should not make a request to observations/updates", async ( ) => {
await testApiMethodNotCalled( inatjs.observations.updates );
} );
} );
describe( "when signed in", ( ) => {
beforeEach( async ( ) => {
await signIn( mockUser, { realm: global.mockRealms[__filename] } );
jest.useFakeTimers( );
} );
afterEach( async ( ) => {
await signOut( { realm: global.mockRealms[__filename] } );
} );
describe( "with unsynced observations", ( ) => {
// Mock inatjs endpoints so they return the right responses for the right test data
inatjs.observations.create.mockImplementation( async ( params, _opts ) => {
const mockObs = mockUnsyncedObservations.find( o => o.uuid === params.observation.uuid );
return makeResponse( [{ id: faker.number.int( ), uuid: mockObs.uuid }] );
} );
inatjs.observations.fetch.mockImplementation( ( uuid, _params, _opts ) => {
const mockObs = mockUnsyncedObservations.find( o => o.uuid === uuid );
// It would be a lot better if this returned something that looks like
// a remote obs, but this works
return Promise.resolve( makeResponse( [mockObs] ) );
} );
inatjs.observation_photos.create.mockImplementation( async ( params, _opts ) => {
const mockObsPhotos = flatten( mockUnsyncedObservations.map( o => o.observationPhotos ) );
const mockObsPhoto = mockObsPhotos.find(
op => op.uuid === params.observation_photo.uuid,
);
// Pretend this takes a bit
await sleep( 500 );
return makeResponse( [{
id: faker.number.int( ),
uuid: mockObsPhoto.uuid,
}] );
} );
inatjs.photos.create.mockImplementation( ( ) => Promise.resolve( makeResponse( [{
id: faker.number.int( ),
}] ) ) );
beforeEach( ( ) => {
writeObservationsToRealm(
mockUnsyncedObservations,
"writing unsynced observations for MyObservations integration test",
);
} );
it( "displays unuploaded status", async () => {
const realm = global.mockRealms[__filename];
expect( realm.objects( "Observation" ).length ).toBeGreaterThan( 0 );
renderAppWithComponent( <MyObservationsContainer /> );
await checkToolbarResetWithUnsyncedObs( );
mockUnsyncedObservations.forEach( obs => {
displayItemByTestId( `UploadIcon.start.${obs.uuid}` );
} );
} );
it( "displays upload in progress status when toolbar tapped", async () => {
renderAppWithComponent( <MyObservationsContainer /> );
await checkToolbarResetWithUnsyncedObs( );
pressItemByTestId( "SyncButton" );
await waitFor( ( ) => {
displayItemByText( /Uploading [1-2] of 2 observations/ );
} );
displayItemByTestId( `UploadIcon.progress.${secondObservation.uuid}` );
const secondQueuedObsItem = screen.getByTestId(
`ObsPressable.${secondObservation.uuid}`,
);
expect( secondQueuedObsItem ).toBeDisabled( );
await waitFor( ( ) => {
displayItemByTestId( `UploadIcon.progress.${firstObservation.uuid}` );
} );
const firstQueuedObsItem = screen.getByTestId(
`ObsPressable.${firstObservation.uuid}`,
);
expect( firstQueuedObsItem ).toBeDisabled( );
await waitFor( ( ) => {
displayItemByText( /2 observations uploaded/ );
} );
expect( firstQueuedObsItem ).not.toBeDisabled( );
} );
it( "displays upload in progress status when individual upload tapped", async () => {
renderAppWithComponent( <MyObservationsContainer /> );
// There are two unuploaded observations, and we are about to upload one of them
await checkToolbarResetWithUnsyncedObs( );
pressItemByTestId( `UploadIcon.start.${firstObservation.uuid}` );
await waitFor( ( ) => {
// Status reflects that we are only uploading one individual observation
displayItemByText( /Uploading 1 observation/ );
} );
displayItemByTestId( `UploadIcon.progress.${firstObservation.uuid}` );
const queuedObsItem = screen.getByTestId(
`ObsPressable.${firstObservation.uuid}`,
);
expect( queuedObsItem ).toBeDisabled( );
displayItemByTestId( `UploadIcon.start.${secondObservation.uuid}` );
const obsItem = screen.getByTestId(
`ObsPressable.${secondObservation.uuid}`,
);
expect( obsItem ).not.toBeDisabled( );
await waitFor( ( ) => {
displayItemByText( /1 observation uploaded/ );
} );
expect( queuedObsItem ).not.toBeDisabled( );
} );
it( "shows error when upload network connection fails", async ( ) => {
renderAppWithComponent( <MyObservationsContainer /> );
await checkToolbarResetWithUnsyncedObs( );
inatjs.observations.create.mockRejectedValueOnce(
new TypeError( "Network request failed" ),
);
pressItemByTestId( "SyncButton" );
const toolbarText = await screen.findByText( /1 upload failed/ );
expect( toolbarText ).toBeVisible( );
// Wait for the toolbar to reset to its default state so there aren't
// any pending async processes that will interfere with other tests
await waitFor( ( ) => {
displayItemByText( /Upload 1 observation/ );
}, { timeout: MS_BEFORE_TOOLBAR_RESET + 1000, interval: 500 } );
} );
} );
describe( "with synced observations", ( ) => {
beforeEach( ( ) => {
writeObservationsToRealm(
mockSyncedObservations,
"MyObservations integration test with synced observations",
);
} );
afterEach( ( ) => {
jest.clearAllMocks( );
} );
it( "should make a request to observations/updates", async ( ) => {
// Let's make sure the mock hasn't already been used
// expect( inatjs.observations.updates ).not.toHaveBeenCalled();
inatjs.observations.updates.mockClear( );
renderAppWithComponent( <MyObservationsContainer /> );
expect( await screen.findByText( /OBSERVATIONS/ ) ).toBeTruthy();
await waitFor( ( ) => {
expect( inatjs.observations.updates ).toHaveBeenCalled( );
} );
} );
it( "hides observation status in grid view", async () => {
const realm = global.mockRealms[__filename];
expect( realm.objects( "Observation" ).length ).toBeGreaterThan( 0 );
renderAppWithComponent( <MyObservationsContainer /> );
displayItemByTestId( "SyncButton" );
mockSyncedObservations.forEach( obs => {
const obsStatus = screen.queryByTestId( `ObsStatus.${obs.uuid}` );
expect( obsStatus ).toBeFalsy();
} );
} );
it( "renders list view on button press", async () => {
const realm = global.mockRealms[__filename];
expect( realm.objects( "Observation" ).length ).toBeGreaterThan( 0 );
renderAppWithComponent( <MyObservationsContainer /> );
const button = await screen.findByTestId( "SegmentedButton.list" );
fireEvent.press( button );
// Awaiting the first observation because using await in the forEach errors out
const firstObs = mockSyncedObservations[0];
await screen.findByTestId( `MyObservations.obsListItem.${firstObs.uuid}` );
mockSyncedObservations.forEach( obs => {
displayItemByTestId( `MyObservations.obsListItem.${obs.uuid}` );
} );
} );
it( "displays observation status in list view in advanced mode", async () => {
const realm = global.mockRealms[__filename];
expect( realm.objects( "Observation" ).length ).toBeGreaterThan( 0 );
renderAppWithComponent( <MyObservationsContainer /> );
const syncIcon = await screen.findByTestId( "SyncButton" );
await waitFor( () => {
expect( syncIcon ).toBeVisible();
} );
mockSyncedObservations.forEach( obs => {
displayItemByTestId( `ObsStatus.${obs.uuid}` );
} );
} );
it( "doesn't throw an error when sync button tapped", async ( ) => {
const realm = global.mockRealms[__filename];
expect( realm.objects( "Observation" ).length ).toBeGreaterThan( 0 );
renderAppWithComponent( <MyObservationsContainer /> );
const syncIcon = await screen.findByTestId( "SyncButton" );
await waitFor( ( ) => {
expect( syncIcon ).toBeVisible( );
} );
expect( ( ) => {
fireEvent.press( syncIcon );
} ).not.toThrow( );
} );
it( "should trigger manual observation sync on pull-to-refresh", async () => {
renderAppWithComponent( <MyObservationsContainer /> );
const myObsList = await screen.findByTestId(
"MyObservationsAnimatedList",
);
fireEvent.scroll( myObsList, {
nativeEvent: {
contentOffset: { y: -100 },
contentSize: { height: 1000, width: 100 },
layoutMeasurement: { height: 500, width: 100 },
},
} );
expect( inatjs.observations.deleted ).toHaveBeenCalled();
} );
describe( "on screen focus", () => {
beforeEach( () => {
zustandStorage.setItem( "lastDeletedSyncTime", "2024-05-01" );
} );
it( "downloads deleted observations from server when screen focused", async () => {
const realm = global.mockRealms[__filename];
expect( realm.objects( "Observation" ).length ).toBeGreaterThan( 0 );
renderAppWithComponent( <MyObservationsContainer /> );
await waitFor( () => {
expect( inatjs.observations.deleted ).toHaveBeenCalledWith(
{
since: "2024-05-01",
},
expect.anything(),
);
} );
} );
it( "deletes local observations if they have been deleted on server", async () => {
inatjs.observations.deleted.mockResolvedValue(
makeResponse( mockDeletedIds ),
);
renderAppWithComponent( <MyObservationsContainer /> );
const deleteSpy = jest.spyOn( global.mockRealms[__filename], "delete" );
await waitFor( () => {
expect( deleteSpy ).toHaveBeenCalledTimes( 1 );
} );
expect(
global.mockRealms[__filename].objects( "Observation" ).length,
).toBe( 1 );
} );
} );
} );
describe( "with no observations", ( ) => {
it( "should show AI camera button to create first observation", async ( ) => {
renderAppWithComponent( <MyObservationsContainer /> );
await waitFor( ( ) => {
const aiCameraButton = screen.getByTestId( "add-obs-button" );
expect( aiCameraButton ).toBeVisible( );
} );
} );
} );
// TODO there are different presentations for each of these states
// describe( "with 1 observation" );
// describe( "with 25 observations" );
// describe( "with 75 observations" );
// describe( "with 125 observations" );
} );
} );