Files
iNaturalistReactNative/tests/unit/uploaders/mediaUploader.test.js
Johannes Klein 9cab2a4008 Fix: sounds getting deleted during upload (#3598)
* Rename for consistency with obs photos

* Refactor to be similar to obs photos

* Include Sound as type

* Revert "Include Sound as type"

This reverts commit 3db88ae031.

* Revert "Refactor to be similar to obs photos"

This reverts commit 8ed7454be0.

* Revert "Rename for consistency with obs photos"

This reverts commit 674b921bab.

* Git mv without changes

* Add types

* Type api sound param

* Type fields s const

* Type fields as const

* Type api param

* Type new param

* The only call site of this function passes in only defined strings

* Adapted from Photo check for local path before deleting

* Type fct params

* Refactor constructor to only have a uri as param

* Refactor Sound constructor to have string as param

* Update Sound.ts

* Remove unused param

* Type fct params

* Type realm

* Also collect unsyncedSounds

* Create operations for sound uploads based on Sound only

* Add Evidence type

* Change a sound upload operation to work with a RealmSound

* Update realmSync to also just pass through Sounds

* Use server ID for attaching obs_sound to obs

* Also split photo pipeline for clarity

This is not a functional change. Also previously, only RealmPhoto s are uploaded, only RealmObservationPhoto s are attached or modified. Same as for sounds now.

* Update realmSync.ts

* Remove log

* Update realmSync.test.js

* Update mocks

* Update Sound map test

* Update prepareMediaForUpload.test.js

* Update prepareMediaForUpload.test.js

* Update mediaUploader.test.js
2026-05-07 21:22:27 +02:00

335 lines
11 KiB
JavaScript

import { createOrUpdateEvidence } from "api/observations";
import inatjs from "inaturalistjs";
import { prepareMediaForUpload } from "uploaders";
import { attachMediaToObservation, uploadObservationMedia } from "uploaders/mediaUploader";
import { trackEvidenceUpload } from "uploaders/utils/progressTracker";
jest.mock( "api/observations" );
jest.mock( "inaturalistjs" );
jest.mock( "uploaders" );
jest.mock( "uploaders/utils/progressTracker" );
const mockedCreateOrUpdateEvidence = jest.mocked( createOrUpdateEvidence );
const mockedPrepareMediaForUpload = jest.mocked( prepareMediaForUpload );
const mockedTrackEvidenceUpload = jest.mocked( trackEvidenceUpload );
describe( "mediaUploader", () => {
beforeEach( () => {
jest.resetAllMocks();
const mockProgress = {
attached: jest.fn(),
uploaded: jest.fn(),
};
mockedTrackEvidenceUpload.mockReturnValue( mockProgress );
mockedCreateOrUpdateEvidence.mockResolvedValue( { id: 123 } );
mockedPrepareMediaForUpload.mockImplementation( ( evidence, type, action, observationId ) => ( {
id: evidence.uuid,
type,
action,
observation_id: observationId,
file_url: evidence.url,
} ) );
} );
describe( "uploadObservationMedia", () => {
it( "should upload photos and sounds in parallel", async () => {
const observation = {
uuid: "obs-uuid-123",
observationPhotos: [
{
wasSynced: () => false,
photo: { uuid: "photo-uuid-1", url: "photo1.jpg" },
},
],
observationSounds: [
{
wasSynced: () => false,
uuid: "sound-uuid-1",
sound: { file_url: "file://soundUploads/sound1.mp3" },
},
],
};
const options = { api_token: "test-token" };
const realm = {};
const result = await uploadObservationMedia( observation, options, realm );
expect( createOrUpdateEvidence ).toHaveBeenCalledTimes( 2 );
expect( result ).toEqual( {
unsyncedObservationPhotos: observation.observationPhotos,
modifiedObservationPhotos: [],
unsyncedObservationSounds: observation.observationSounds,
} );
} );
it( "should handle empty media arrays", async () => {
const observation = {
uuid: "obs-uuid-123",
observationPhotos: [],
observationSounds: [],
};
const options = { api_token: "test-token" };
const realm = {};
const result = await uploadObservationMedia( observation, options, realm );
expect( createOrUpdateEvidence ).not.toHaveBeenCalled();
expect( result ).toEqual( {
unsyncedObservationPhotos: [],
modifiedObservationPhotos: [],
unsyncedObservationSounds: [],
} );
} );
it( "should handle photos that need updates", async () => {
const observation = {
uuid: "obs-uuid-123",
observationPhotos: [
{
wasSynced: () => true,
needsSync: () => true,
photo: { uuid: "photo-uuid-1", url: "photo1.jpg" },
},
],
observationSounds: [],
};
const options = { api_token: "test-token" };
const realm = {};
const result = await uploadObservationMedia( observation, options, realm );
expect( createOrUpdateEvidence ).not.toHaveBeenCalled();
expect( result.modifiedObservationPhotos.length ).toBe( 1 );
} );
it( "should handle missing photo objects gracefully", async () => {
const observation = {
uuid: "obs-uuid-123",
observationPhotos: [
{
wasSynced: () => false,
get photo() {
throw new Error( "No object with key photo-uuid-missing" );
},
},
{
wasSynced: () => false,
photo: { uuid: "photo-uuid-1", url: "photo1.jpg" },
},
],
observationSounds: [],
};
const options = { api_token: "test-token" };
const realm = {};
const result = await uploadObservationMedia( observation, options, realm );
expect( createOrUpdateEvidence ).toHaveBeenCalledTimes( 1 );
expect( result.unsyncedObservationPhotos.length ).toBe( 2 );
} );
} );
describe( "attachMediaToObservation", () => {
it( "should attach photos and sounds to an observation", async () => {
const observationUUID = "obs-uuid-123";
const mediaItems = {
unsyncedObservationPhotos: [
{ uuid: "photo-uuid-1", url: "photo1.jpg" },
],
modifiedObservationPhotos: [
{ uuid: "photo-uuid-2", url: "photo2.jpg" },
],
unsyncedObservationSounds: [
{ uuid: "sound-uuid-1", url: "sound1.mp3" },
],
};
const options = { api_token: "test-token" };
const realm = {};
await attachMediaToObservation( observationUUID, mediaItems, options, realm );
expect( createOrUpdateEvidence ).toHaveBeenCalledTimes( 3 );
expect( mockedPrepareMediaForUpload ).toHaveBeenCalledWith(
mediaItems.unsyncedObservationPhotos[0],
"ObservationPhoto",
"attach",
observationUUID,
);
expect( mockedPrepareMediaForUpload ).toHaveBeenCalledWith(
mediaItems.unsyncedObservationSounds[0],
"ObservationSound",
"attach",
observationUUID,
);
expect( mockedPrepareMediaForUpload ).toHaveBeenCalledWith(
mediaItems.modifiedObservationPhotos[0],
"ObservationPhoto",
"update",
observationUUID,
);
expect( mockedCreateOrUpdateEvidence ).toHaveBeenCalledWith(
inatjs.observation_photos.create,
expect.any( Object ),
options,
);
expect( mockedCreateOrUpdateEvidence ).toHaveBeenCalledWith(
inatjs.observation_sounds.create,
expect.any( Object ),
options,
);
expect( mockedCreateOrUpdateEvidence ).toHaveBeenCalledWith(
inatjs.observation_photos.update,
expect.any( Object ),
options,
);
} );
it( "should handle empty media arrays", async () => {
const observationUUID = "obs-uuid-123";
const mediaItems = {
unsyncedObservationPhotos: [],
modifiedObservationPhotos: [],
unsyncedObservationSounds: [],
};
const options = { api_token: "test-token" };
const realm = {};
await attachMediaToObservation( observationUUID, mediaItems, options, realm );
expect( createOrUpdateEvidence ).not.toHaveBeenCalled();
} );
} );
describe( "filterMediaForUpload", () => {
it( "should correctly filter media for upload", async () => {
const observation = {
uuid: "obs-uuid-123",
observationPhotos: [
{ wasSynced: () => false, photo: { uuid: "photo-uuid-1", url: "photo1.jpg" } },
{
wasSynced: () => true,
needsSync: () => true,
photo: { uuid: "photo-uuid-2", url: "photo2.jpg" },
},
{
wasSynced: () => true,
needsSync: () => false,
photo: { uuid: "photo-uuid-3", url: "photo3.jpg" },
},
],
observationSounds: [
{
wasSynced: () => false,
uuid: "sound-uuid-1",
sound: { file_url: "file://soundUploads/sound1.mp3" },
},
{
wasSynced: () => true,
uuid: "sound-uuid-2",
sound: { file_url: "file://soundUploads/sound2.mp3" },
},
],
};
const options = { api_token: "test-token" };
const realm = {};
const result = await uploadObservationMedia( observation, options, realm );
expect( result.unsyncedObservationPhotos.length ).toBe( 1 );
expect( result.modifiedObservationPhotos.length ).toBe( 1 );
expect( result.unsyncedObservationSounds.length ).toBe( 1 );
} );
} );
describe( "progress tracking", () => {
it( "should track progress for upload evidence operations", async () => {
const observation = {
uuid: "obs-uuid-123",
observationPhotos: [
{ wasSynced: () => false, photo: { uuid: "photo-uuid-1", url: "photo1.jpg" } },
],
observationSounds: [],
};
const options = { api_token: "test-token" };
const realm = {};
const mockProgress = {
attached: jest.fn(),
uploaded: jest.fn(),
};
mockedTrackEvidenceUpload.mockReturnValue( mockProgress );
await uploadObservationMedia( observation, options, realm );
expect( trackEvidenceUpload ).toHaveBeenCalledWith( "obs-uuid-123" );
expect( mockProgress.uploaded ).toHaveBeenCalled();
expect( mockProgress.attached ).not.toHaveBeenCalled();
} );
it( "should track progress for attach operations", async () => {
const observationUUID = "obs-uuid-123";
const mediaItems = {
unsyncedObservationPhotos: [
{ uuid: "photo-uuid-1", url: "photo1.jpg" },
],
modifiedObservationPhotos: [],
unsyncedObservationSounds: [],
};
const options = { api_token: "test-token" };
const realm = {};
const mockProgress = {
attached: jest.fn(),
uploaded: jest.fn(),
};
mockedTrackEvidenceUpload.mockReturnValue( mockProgress );
await attachMediaToObservation( observationUUID, mediaItems, options, realm );
expect( trackEvidenceUpload ).toHaveBeenCalledWith( "obs-uuid-123" );
expect( mockProgress.attached ).toHaveBeenCalled();
} );
} );
describe( "error handling", () => {
it( "should handle API errors gracefully", async () => {
const observation = {
uuid: "obs-uuid-123",
observationPhotos: [
{ wasSynced: () => false, photo: { uuid: "photo-uuid-1", url: "photo1.jpg" } },
],
observationSounds: [],
};
const options = { api_token: "test-token" };
const realm = {};
mockedCreateOrUpdateEvidence.mockRejectedValue( new Error( "API Error" ) );
await expect( uploadObservationMedia( observation, options, realm ) )
.rejects.toThrow( "API Error" );
} );
it( "should filter out null photos", async () => {
const observation = {
uuid: "obs-uuid-123",
observationPhotos: [
{
wasSynced: () => false,
photo: null,
},
],
observationSounds: [],
};
const options = { api_token: "test-token" };
const realm = {};
const result = await uploadObservationMedia( observation, options, realm );
expect( createOrUpdateEvidence ).not.toHaveBeenCalled();
expect( result.unsyncedObservationPhotos.length ).toBe( 1 );
} );
} );
} );