// 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" ); return { __esModule: true, ...originalModule, RealmContext: { ...originalModule.RealmContext, useRealm: ( ) => global.mockRealms[mockRealmIdentifier], useQuery: ( ) => [], }, }; } ); 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( ); // const { findByTestId } = renderAppWithComponent( ); // 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( ); 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( ); await checkToolbarResetWithUnsyncedObs( ); mockUnsyncedObservations.forEach( obs => { displayItemByTestId( `UploadIcon.start.${obs.uuid}` ); } ); } ); it( "displays upload in progress status when toolbar tapped", async () => { renderAppWithComponent( ); 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( ); // 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( ); 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( ); 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( ); 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( ); 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( ); 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( ); 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( ); 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( ); 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( ); 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( ); 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" ); } ); } );