diff --git a/src/api/observations.js b/src/api/observations.js index ddc65e7b0..59c3d099d 100644 --- a/src/api/observations.js +++ b/src/api/observations.js @@ -65,6 +65,28 @@ const fetchRemoteObservation = async ( } }; +const fetchRemoteObservations = async ( + uuids: Array, + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const response = await inatjs.observations.fetch( + uuids, + params, + opts + ); + if ( !response ) { return null; } + const { results } = response; + if ( results?.length > 0 ) { + return results.map( mapToLocalSchema ); + } + return null; + } catch ( e ) { + return handleError( e ); + } +}; + const markAsReviewed = async ( params: Object = {}, opts: Object = {} ): Promise => { try { return await inatjs.observations.review( params, opts ); @@ -205,6 +227,7 @@ export { fetchObservationUpdates, fetchObservers, fetchRemoteObservation, + fetchRemoteObservations, fetchSpeciesCounts, fetchUnviewedObservationUpdatesCount, markAsReviewed, diff --git a/src/components/ObsDetails/ObsDetailsContainer.js b/src/components/ObsDetails/ObsDetailsContainer.js index 9684edb9b..dd20ea520 100644 --- a/src/components/ObsDetails/ObsDetailsContainer.js +++ b/src/components/ObsDetails/ObsDetailsContainer.js @@ -22,8 +22,9 @@ import { useObservationsUpdates, useTranslation } from "sharedHooks"; -import useRemoteObservation, -{ fetchRemoteObservationKey } from "sharedHooks/useRemoteObservation"; +import useRemoteObservation, { + fetchRemoteObservationKey +} from "sharedHooks/useRemoteObservation"; import { ACTIVITY_TAB_ID, DETAILS_TAB_ID } from "stores/createLayoutSlice"; import useStore from "stores/useStore"; diff --git a/src/sharedHooks/useInfiniteNotificationsScroll.js b/src/sharedHooks/useInfiniteNotificationsScroll.js index 81cc49229..42733bd15 100644 --- a/src/sharedHooks/useInfiniteNotificationsScroll.js +++ b/src/sharedHooks/useInfiniteNotificationsScroll.js @@ -1,12 +1,17 @@ // @flow import { useInfiniteQuery } from "@tanstack/react-query"; -import { fetchObservationUpdates } from "api/observations"; +import { fetchObservationUpdates, fetchRemoteObservations } from "api/observations"; import { getJWT } from "components/LoginSignUp/AuthenticationService"; import { flatten } from "lodash"; +import { RealmContext } from "providers/contexts"; +import { useCallback } from "react"; +import Observation from "realmModels/Observation"; import { reactQueryRetry } from "sharedHelpers/logging"; import { useCurrentUser } from "sharedHooks"; +const { useRealm } = RealmContext; + const BASE_PARAMS = { observations_by: "owner", fields: "all", @@ -17,6 +22,16 @@ const BASE_PARAMS = { const useInfiniteNotificationsScroll = ( ): Object => { const currentUser = useCurrentUser( ); + const realm = useRealm( ); + + const fetchObsByUUIDs = useCallback( async ( uuids, authOptions ) => { + const observations = await fetchRemoteObservations( + uuids, + { fields: Observation.FIELDS }, + authOptions + ); + Observation.upsertRemoteObservations( observations, realm ); + }, [realm] ); const infQueryResult = useInfiniteQuery( { queryKey: ["useInfiniteNotificationsScroll"], @@ -35,6 +50,10 @@ const useInfiniteNotificationsScroll = ( ): Object => { } const response = await fetchObservationUpdates( params, options ); + const obsUUIDs = response?.map( obsUpdate => obsUpdate.resource_uuid ) || []; + if ( obsUUIDs.length > 0 ) { + await fetchObsByUUIDs( obsUUIDs, options ); + } return response; }, diff --git a/tests/factories/RemoteComment.js b/tests/factories/RemoteComment.js index a49b2c47d..47dca68d3 100644 --- a/tests/factories/RemoteComment.js +++ b/tests/factories/RemoteComment.js @@ -1,5 +1,15 @@ import { define } from "factoria"; +import userFactory from "./RemoteUser"; + export default define( "RemoteComment", faker => ( { + body: faker.lorem.paragraph( ), + created_at: faker.date.past( ).toISOString( ), + id: faker.number.int( ), + parent_id: faker.number.int( ), + parent_type: "Observation", + updated_at: faker.date.past( ).toISOString( ), + user: userFactory( "RemoteUser" ), + user_id: faker.number.int( ), uuid: faker.string.uuid( ) } ) ); diff --git a/tests/helpers/render.js b/tests/helpers/render.js index 180d3ae81..988393d42 100644 --- a/tests/helpers/render.js +++ b/tests/helpers/render.js @@ -121,6 +121,7 @@ function renderHook( renderCallback, options = {} ) { } export { + queryClient, renderApp, renderAppWithComponent, renderAppWithObservations, diff --git a/tests/integration/Notifications.test.tsx b/tests/integration/Notifications.test.tsx new file mode 100644 index 000000000..9d55e4231 --- /dev/null +++ b/tests/integration/Notifications.test.tsx @@ -0,0 +1,109 @@ +import { screen, waitFor } from "@testing-library/react-native"; +import NotificationsContainer from "components/Notifications/NotificationsContainer"; +import inatjs from "inaturalistjs"; +import React from "react"; +import factory, { makeResponse } from "tests/factory"; +import { queryClient, renderAppWithComponent } from "tests/helpers/render"; +import setupUniqueRealm from "tests/helpers/uniqueRealm"; +import { signIn, signOut } from "tests/helpers/user"; + +// 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] + } + }; +} ); +beforeAll( uniqueRealmBeforeAll ); +afterAll( uniqueRealmAfterAll ); +// /UNIQUE REALM SETUP + +const mockUser = factory( "LocalUser" ); + +function makeMockObsUpdatesResponse( mockObs ) { + const mockObservation = mockObs || factory( "RemoteObservation", { + user: mockUser + } ); + const mockComment = factory( "RemoteComment", { parent_id: mockObservation.id } ); + const mockUpdate = { + id: 123, + created_at: mockComment.created_at, + comment: mockComment, + comment_id: mockComment.id, + notifier_id: mockComment.id, + notifier_type: "Comment", + notification: "activity", + resource_owner_id: mockUser.id, + resource_type: "Observation", + resource_id: mockObservation.id, + resource_uuid: mockObservation.uuid, + viewed: false + }; + const obsUpdatesResponse = makeResponse( [mockUpdate] ); + inatjs.observations.updates.mockResolvedValue( obsUpdatesResponse ); + inatjs.observations.fetch.mockResolvedValue( makeResponse( [mockObservation] ) ); + return obsUpdatesResponse; +} + +describe( "Notifications", () => { + beforeEach( async () => { + jest.useFakeTimers( ); + signIn( mockUser, { realm: global.mockRealms[__filename] } ); + } ); + + afterEach( () => { + jest.clearAllMocks(); + signOut( { realm: global.mockRealms[__filename] } ); + queryClient.clear( ); + } ); + + it( "should show a notification", async ( ) => { + makeMockObsUpdatesResponse( ); + renderAppWithComponent( ); + await waitFor( ( ) => { + expect( inatjs.observations.updates ).toHaveBeenCalled( ); + } ); + expect( + await screen.findByText( /added a comment to an observation by you/ ) + ).toBeVisible( ); + } ); + + it( "should show a photo for an observation not in the local database", async ( ) => { + const mockObservation = factory( "RemoteObservation", { + observation_photos: [ + factory( "RemoteObservationPhoto" ) + ] + } ); + const localObservation = global.mockRealms[__filename].objectForPrimaryKey( + "Observation", + mockObservation.uuid + ); + expect( localObservation ).toBeFalsy( ); + const photoUrl = mockObservation.observation_photos[0].photo.url; + expect( photoUrl ).toBeTruthy( ); + const response = makeMockObsUpdatesResponse( mockObservation ); + const { comment } = response.results[0]; + expect( response.results[0].resource_uuid ).toEqual( mockObservation.uuid ); + renderAppWithComponent( ); + expect( await screen.findByText( comment.user.login ) ).toBeVisible( ); + const localObservationAfter = global.mockRealms[__filename].objectForPrimaryKey( + "Observation", + mockObservation.uuid + ); + expect( localObservationAfter ).toBeTruthy( ); + const image = await screen.findByTestId( "ObservationIcon.photo" ); + await waitFor( () => { + expect( image.props.source ).toStrictEqual( { uri: photoUrl } ); + } ); + } ); +} );