import { AssetVisibility, LoginResponseDto } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('/map', () => { let websocket: Socket; let partnerWebsocket: Socket; let admin: LoginResponseDto; let partner: LoginResponseDto; let partnerArchivedAssetId: string; let adminArchivedAssetId: string; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); partner = await utils.userSetup(admin.accessToken, createUserDto.user1); websocket = await utils.connectWebsocket(admin.accessToken); partnerWebsocket = await utils.connectWebsocket(partner.accessToken); const adminFiles = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg']; const adminArchivedFile = 'metadata/dates/datetimeoriginal-gps.jpg'; const partnerFile = 'metadata/gps-position/thompson-springs.jpg'; utils.resetEvents(); const uploadFile = async (accessToken: string, input: string) => { const filepath = join(testAssetDir, input); const { id } = await utils.createAsset(accessToken, { assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, }); await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); return id; }; await Promise.all(adminFiles.map((f) => uploadFile(admin.accessToken, f))); [adminArchivedAssetId, partnerArchivedAssetId] = await Promise.all([ uploadFile(admin.accessToken, adminArchivedFile), uploadFile(partner.accessToken, partnerFile), ]); await Promise.all([ utils.archiveAssets(admin.accessToken, [adminArchivedAssetId]), utils.archiveAssets(partner.accessToken, [partnerArchivedAssetId]), utils.createPartner(partner.accessToken, admin.userId), ]); }); afterAll(() => { utils.disconnectWebsocket(websocket); utils.disconnectWebsocket(partnerWebsocket); }); describe('GET /map/markers', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/map/markers'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should get map markers for all non-archived assets', async () => { const { status, body } = await request(app) .get('/map/markers') .query({ visibility: AssetVisibility.Timeline }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(2); expect(body).toEqual([ { city: 'Palisade', country: 'United States of America', id: expect.any(String), lat: expect.closeTo(39.115), lon: expect.closeTo(-108.400_968), state: 'Colorado', }, { city: 'Ralston', country: 'United States of America', id: expect.any(String), lat: expect.closeTo(41.2203), lon: expect.closeTo(-96.071_625), state: 'Nebraska', }, ]); }); it('should not expose partner archived asset locations', async () => { const { status, body } = await request(app) .get('/map/markers') .query({ withPartners: true, isArchived: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); const ids = body.map((m: { id: string }) => m.id); expect(ids).not.toContain(partnerArchivedAssetId); expect(ids).toContain(adminArchivedAssetId); }); it('should include own archived asset locations', async () => { const { status, body } = await request(app) .get('/map/markers') .query({ isArchived: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body.map((m: { id: string }) => m.id)).toContain(adminArchivedAssetId); }); it('should get all map markers', async () => { const { status, body } = await request(app) .get('/map/markers') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toEqual([ { city: 'Palisade', country: 'United States of America', id: expect.any(String), lat: expect.closeTo(39.115), lon: expect.closeTo(-108.400_968), state: 'Colorado', }, { city: 'Ralston', country: 'United States of America', id: expect.any(String), lat: expect.closeTo(41.2203), lon: expect.closeTo(-96.071_625), state: 'Nebraska', }, ]); }); }); describe('GET /map/reverse-geocode', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/map/reverse-geocode'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should throw an error if a lat is not provided', async () => { const { status, body } = await request(app) .get('/map/reverse-geocode?lon=123') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); expect(body).toEqual( errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), ); }); it('should throw an error if a lat is not a number', async () => { const { status, body } = await request(app) .get('/map/reverse-geocode?lat=abc&lon=123.456') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); expect(body).toEqual( errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), ); }); it('should throw an error if a lat is out of range', async () => { const { status, body } = await request(app) .get('/map/reverse-geocode?lat=91&lon=123.456') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); expect(body).toEqual( errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]), ); }); it('should throw an error if a lon is not provided', async () => { const { status, body } = await request(app) .get('/map/reverse-geocode?lat=75') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); expect(body).toEqual( errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]), ); }); const reverseGeocodeTestCases = [ { name: 'Vaucluse', lat: -33.858_977_058_663_13, lon: 151.278_490_730_270_48, results: [{ city: 'Vaucluse', state: 'New South Wales', country: 'Australia' }], }, { name: 'Ravenhall', lat: -37.765_732_399_174_75, lon: 144.752_453_164_883_3, results: [{ city: 'Ravenhall', state: 'Victoria', country: 'Australia' }], }, { name: 'Scarborough', lat: -31.894_346_156_789_997, lon: 115.757_617_103_904_64, results: [{ city: 'Scarborough', state: 'Western Australia', country: 'Australia' }], }, ]; it.each(reverseGeocodeTestCases)(`should resolve to $name`, async ({ lat, lon, results }) => { const { status, body } = await request(app) .get(`/map/reverse-geocode?lat=${lat}&lon=${lon}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(Array.isArray(body)).toBe(true); expect(body.length).toBe(results.length); expect(body).toEqual(results); }); }); });