mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
* (lint) MOB-1063 enforce trailing commas * autofix trailing commas * manually fix newly introduced maxlen violations * add trailing comma convention to i18n build
452 lines
17 KiB
JavaScript
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";
|
|
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( <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" );
|
|
} );
|
|
} );
|