mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512)
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).
The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.
Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.
StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.
The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.
The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
This commit is contained in:
@@ -189,6 +189,79 @@ export function dayStartEpochSeconds(day: string, tzOffsetMinutes = 0): number {
|
||||
}
|
||||
|
||||
|
||||
// 'YYYY:MM:DD HH:MM:SS' as EXIF writes it. The dashed date is what some
|
||||
// converters produce and what exifr's own reviver accepted, so it keeps working,
|
||||
// and so does a field written with one digit ('2026:5:30 9:05:00'), which that
|
||||
// reviver read as well. Whatever follows the time is checked on its own below.
|
||||
// No end anchor on purpose: a greedy tail in front of '$' makes a long run of
|
||||
// digits cost quadratic time, and the stamp is text any upload can set.
|
||||
const EXIF_STAMP = /^(\d{4})[:-](\d{1,2})[:-](\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?/;
|
||||
// A stamp with nanoseconds and a zone is 36 characters. A longer one is no
|
||||
// stamp, and is not worth reading.
|
||||
const EXIF_STAMP_MAX = 64;
|
||||
// '+02:00' is what the spec asks for; '+0200', 'Z', 'UTC' and 'GMT' say the same thing.
|
||||
const EXIF_ZONE = /^(?:(Z|UTC|GMT)|([+-])(\d{2}):?(\d{2}))$/i;
|
||||
|
||||
/** An EXIF zone ('+02:00', an OffsetTime* tag or a stamp's own) in minutes east of UTC, or null when it is not one. */
|
||||
function exifZoneMinutes(value: unknown): number | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const m = EXIF_ZONE.exec(value.trim());
|
||||
if (!m) return null;
|
||||
if (m[1]) return 0;
|
||||
const hours = Number(m[3]);
|
||||
const minutes = Number(m[4]);
|
||||
// Real zones run from -12:00 to +14:00; anything past that is a broken tag.
|
||||
if (hours > 14 || minutes > 59) return null;
|
||||
return (m[2] === '-' ? -1 : 1) * (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The instant an EXIF capture stamp names, as an ISO string, or null.
|
||||
*
|
||||
* EXIF stores the photographer's wall clock ('2026:05:30 15:52:19') and, in a
|
||||
* separate tag, the zone it was read in ('+02:00'). Handing the file to exifr
|
||||
* with its defaults turns the stamp into a Date in the server's zone and never
|
||||
* looks at the offset, so a photo taken in France and uploaded to a server in
|
||||
* Chicago was stored seven hours late (#2512). This takes the raw stamp and
|
||||
* applies the first well-formed offset in `offsets`. A stamp that carries its
|
||||
* own zone ('... UTC', 'Z', '+02:00', the form exifr's reviver comment names)
|
||||
* needs none of them: that zone is the most specific answer there is.
|
||||
*
|
||||
* Without one, the server's zone is all there is to go on. That is what every
|
||||
* upload got before, and it stays right for a server in the photographer's zone.
|
||||
*
|
||||
* A stamp that is not a real moment (blank, the zeros a camera without a clock
|
||||
* writes, 30 February, 25 o'clock) is null, not whatever Date rolls it over to.
|
||||
*/
|
||||
export function exifCaptureInstant(stamp: unknown, offsets: unknown[]): string | null {
|
||||
if (typeof stamp !== 'string') return null;
|
||||
const text = stamp.trim();
|
||||
if (text.length > EXIF_STAMP_MAX) return null;
|
||||
const m = EXIF_STAMP.exec(text);
|
||||
if (!m) return null;
|
||||
const suffix = text.slice(m[0].length).trim();
|
||||
// After the time there is either nothing or a zone; anything else ('CEST',
|
||||
// '+2', 'later') is not a stamp. A zone that is out of range leaves the
|
||||
// decision to the tags.
|
||||
if (suffix && !EXIF_ZONE.test(suffix)) return null;
|
||||
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
||||
const wall = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
||||
// Date.UTC quietly rolls an impossible part into the next one (and reads
|
||||
// years below 100 as 19xx), so the parts have to survive the round trip. It
|
||||
// is checked on the UTC reading so a local DST gap does not count as invalid.
|
||||
if (wall.getUTCFullYear() !== year || wall.getUTCMonth() !== month - 1 || wall.getUTCDate() !== day
|
||||
|| wall.getUTCHours() !== hour || wall.getUTCMinutes() !== minute || wall.getUTCSeconds() !== second) {
|
||||
return null;
|
||||
}
|
||||
const offset = exifZoneMinutes(suffix) ?? offsets.map(exifZoneMinutes).find(o => o !== null);
|
||||
const instant = offset == null
|
||||
? new Date(year, month - 1, day, hour, minute, second)
|
||||
: new Date(wall.getTime() - offset * 60000);
|
||||
return instant.toISOString();
|
||||
}
|
||||
|
||||
|
||||
export type AssetInfo = {
|
||||
id: string;
|
||||
takenAt: string | null;
|
||||
|
||||
@@ -3,6 +3,60 @@ import exifr from 'exifr';
|
||||
import { PhotoResolverService } from './photo-resolver.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
import { exifCaptureInstant } from './memories.helpers';
|
||||
|
||||
/**
|
||||
* What readCapture asks exifr for.
|
||||
*
|
||||
* `latitude` and `longitude` are not tags. exifr works them out from the four
|
||||
* GPS tags below, and only when it has read those. A pick of just the two
|
||||
* derived names reads no GPS at all, which is how every upload used to lose
|
||||
* its location (#2512).
|
||||
*/
|
||||
const CAPTURE_TAGS = [
|
||||
'DateTimeOriginal',
|
||||
'CreateDate',
|
||||
'OffsetTimeOriginal',
|
||||
'OffsetTimeDigitized',
|
||||
'OffsetTime',
|
||||
'GPSLatitude',
|
||||
'GPSLatitudeRef',
|
||||
'GPSLongitude',
|
||||
'GPSLongitudeRef',
|
||||
] as const;
|
||||
|
||||
/** When and where an uploaded file says it was taken. */
|
||||
type LocalCapture = { takenAt: string | null; lat: number | null; lng: number | null };
|
||||
|
||||
type Exif = Partial<Record<(typeof CAPTURE_TAGS)[number] | 'latitude' | 'longitude', unknown>>;
|
||||
|
||||
function coordinate(value: unknown, limit: number): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= limit ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture time and place in a file's EXIF, or null when it names neither.
|
||||
* Throws for a file exifr cannot read at all.
|
||||
*/
|
||||
async function readCapture(abs: string): Promise<LocalCapture | null> {
|
||||
// Raw values on purpose: see exifCaptureInstant for why the stamps are not
|
||||
// left to exifr's reviver.
|
||||
const parsed = (await exifr.parse(abs, { pick: [...CAPTURE_TAGS], reviveValues: false })) as Exif | undefined;
|
||||
if (!parsed) return null;
|
||||
|
||||
// Each stamp has an offset tag of its own. The others stand in for a missing
|
||||
// one: a camera writes all three from the same clock.
|
||||
const { OffsetTimeOriginal: original, OffsetTimeDigitized: digitized, OffsetTime: modified } = parsed;
|
||||
const takenAt = exifCaptureInstant(parsed.DateTimeOriginal, [original, digitized, modified])
|
||||
?? exifCaptureInstant(parsed.CreateDate, [digitized, original, modified]);
|
||||
const lat = coordinate(parsed.latitude, 90);
|
||||
const lng = coordinate(parsed.longitude, 180);
|
||||
// A receiver without a fix can write zeros into the GPS tags instead of
|
||||
// leaving them out. Taken at its word, 0,0 pins the photo to open sea in the
|
||||
// Gulf of Guinea on the journey map, so it counts as no location.
|
||||
const hasPair = lat != null && lng != null && !(lat === 0 && lng === 0);
|
||||
return takenAt || hasPair ? { takenAt, lat: hasPair ? lat : null, lng: hasPair ? lng : null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the provider when and where a photo was taken, and record it (#1614).
|
||||
@@ -70,9 +124,7 @@ export class PhotoCaptureBackfillService {
|
||||
* here already stripped. Nothing to read is the expected outcome far more often
|
||||
* than not — hence no logging on the empty case.
|
||||
*/
|
||||
private async readLocalExif(
|
||||
filePath?: string | null,
|
||||
): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> {
|
||||
private async readLocalExif(filePath?: string | null): Promise<LocalCapture | null> {
|
||||
if (!filePath) return null;
|
||||
// photos.file_path is uploads-relative 'journey/<name>' by every writer;
|
||||
// anything else reads as a miss (same rule as photo-resolver). Central key
|
||||
@@ -81,25 +133,12 @@ export class PhotoCaptureBackfillService {
|
||||
if (!filePath.startsWith('journey/')) return null;
|
||||
const name = filePath.slice('journey/'.length);
|
||||
|
||||
type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number };
|
||||
let parsed: Exif | null;
|
||||
try {
|
||||
parsed = await this.storage.withLocalFile('journey', name, async abs =>
|
||||
(await exifr.parse(abs, {
|
||||
pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'],
|
||||
})) as Exif | null,
|
||||
);
|
||||
return await this.storage.withLocalFile('journey', name, readCapture);
|
||||
} catch {
|
||||
// A vanished object, an invalid key, not an image, a truncated upload,
|
||||
// a video — none of it is an error here.
|
||||
return null;
|
||||
}
|
||||
if (!parsed) return null;
|
||||
|
||||
const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null;
|
||||
const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null;
|
||||
const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null;
|
||||
const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null;
|
||||
return takenAt || lat != null ? { takenAt, lat, lng } : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,9 +253,10 @@ export class StorageService {
|
||||
}
|
||||
|
||||
const tmp = path.join(this.tempDir(), randomUUID());
|
||||
const { stream } = await driver.getStream(key);
|
||||
await pipeline(stream, fs.createWriteStream(tmp));
|
||||
try {
|
||||
const { stream } = await driver.getStream(key);
|
||||
// Inside the try, so a download cut short does not leave its part behind.
|
||||
await pipeline(stream, fs.createWriteStream(tmp));
|
||||
return await fn(tmp);
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* A real JPEG carrying the capture tags a phone writes, built byte by byte so
|
||||
* no actual person's photo has to live in the repo.
|
||||
*
|
||||
* The image data is tests/fixtures/small-image.jpg (a decodable 1x1 JPEG); its
|
||||
* JFIF APP0 is swapped for an Exif APP1 the way a camera lays the file out:
|
||||
* SOI, APP1 "Exif", then the rest. Every value is written as the raw tag string
|
||||
* or rational, so a test can also hand in a malformed one.
|
||||
*/
|
||||
export interface ExifJpegTags {
|
||||
/** 0x9003, e.g. '2026:05:30 15:52:19'. */
|
||||
DateTimeOriginal?: string;
|
||||
/** 0x9004 (DateTimeDigitized). */
|
||||
CreateDate?: string;
|
||||
/** 0x9010, the offset of the IFD0 DateTime. */
|
||||
OffsetTime?: string;
|
||||
/** 0x9011, the offset of DateTimeOriginal. */
|
||||
OffsetTimeOriginal?: string;
|
||||
/** 0x9012, the offset of DateTimeDigitized. */
|
||||
OffsetTimeDigitized?: string;
|
||||
/** Decimal degrees, stored as the usual degree/minute/second rationals plus N/S and E/W. */
|
||||
gps?: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
const BASE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
|
||||
const BYTE = 1;
|
||||
const ASCII = 2;
|
||||
const LONG = 4;
|
||||
const RATIONAL = 5;
|
||||
|
||||
interface Entry { tag: number; type: number; count: number; data: Buffer }
|
||||
|
||||
function ascii(tag: number, value: string): Entry {
|
||||
const data = Buffer.from(`${value}\0`, 'latin1');
|
||||
return { tag, type: ASCII, count: data.length, data };
|
||||
}
|
||||
|
||||
function long(tag: number, value: number): Entry {
|
||||
const data = Buffer.alloc(4);
|
||||
data.writeUInt32LE(value);
|
||||
return { tag, type: LONG, count: 1, data };
|
||||
}
|
||||
|
||||
function rationals(tag: number, pairs: Array<[number, number]>): Entry {
|
||||
const data = Buffer.alloc(pairs.length * 8);
|
||||
pairs.forEach(([num, den], i) => {
|
||||
data.writeUInt32LE(num, i * 8);
|
||||
data.writeUInt32LE(den, i * 8 + 4);
|
||||
});
|
||||
return { tag, type: RATIONAL, count: pairs.length, data };
|
||||
}
|
||||
|
||||
function dms(deg: number): Array<[number, number]> {
|
||||
const abs = Math.abs(deg);
|
||||
const d = Math.floor(abs);
|
||||
const m = Math.floor((abs - d) * 60);
|
||||
const s = Math.round(((abs - d) * 60 - m) * 60 * 10000);
|
||||
return [[d, 1], [m, 1], [s, 10000]];
|
||||
}
|
||||
|
||||
function ifdSize(entries: Entry[]): number {
|
||||
const outOfLine = entries.reduce((sum, e) => sum + (e.data.length > 4 ? e.data.length + (e.data.length % 2) : 0), 0);
|
||||
return 2 + entries.length * 12 + 4 + outOfLine;
|
||||
}
|
||||
|
||||
/** One IFD at TIFF offset `at`, entries sorted by tag as the spec wants. */
|
||||
function writeIfd(entries: Entry[], at: number): Buffer {
|
||||
const sorted = [...entries].sort((a, b) => a.tag - b.tag);
|
||||
const head = Buffer.alloc(2 + sorted.length * 12 + 4);
|
||||
const tail: Buffer[] = [];
|
||||
let dataAt = at + head.length;
|
||||
head.writeUInt16LE(sorted.length, 0);
|
||||
sorted.forEach((e, i) => {
|
||||
const o = 2 + i * 12;
|
||||
head.writeUInt16LE(e.tag, o);
|
||||
head.writeUInt16LE(e.type, o + 2);
|
||||
head.writeUInt32LE(e.count, o + 4);
|
||||
if (e.data.length <= 4) {
|
||||
e.data.copy(head, o + 8);
|
||||
} else {
|
||||
head.writeUInt32LE(dataAt, o + 8);
|
||||
const padded = e.data.length % 2 ? Buffer.concat([e.data, Buffer.alloc(1)]) : e.data;
|
||||
tail.push(padded);
|
||||
dataAt += padded.length;
|
||||
}
|
||||
});
|
||||
head.writeUInt32LE(0, head.length - 4);
|
||||
return Buffer.concat([head, ...tail]);
|
||||
}
|
||||
|
||||
export function jpegWithExif(tags: ExifJpegTags): Buffer {
|
||||
const exifEntries: Entry[] = [];
|
||||
if (tags.DateTimeOriginal !== undefined) exifEntries.push(ascii(0x9003, tags.DateTimeOriginal));
|
||||
if (tags.CreateDate !== undefined) exifEntries.push(ascii(0x9004, tags.CreateDate));
|
||||
if (tags.OffsetTime !== undefined) exifEntries.push(ascii(0x9010, tags.OffsetTime));
|
||||
if (tags.OffsetTimeOriginal !== undefined) exifEntries.push(ascii(0x9011, tags.OffsetTimeOriginal));
|
||||
if (tags.OffsetTimeDigitized !== undefined) exifEntries.push(ascii(0x9012, tags.OffsetTimeDigitized));
|
||||
|
||||
const gpsEntries: Entry[] = [];
|
||||
if (tags.gps) {
|
||||
gpsEntries.push({ tag: 0x0000, type: BYTE, count: 4, data: Buffer.from([2, 2, 0, 0]) });
|
||||
gpsEntries.push(ascii(0x0001, tags.gps.lat < 0 ? 'S' : 'N'));
|
||||
gpsEntries.push(rationals(0x0002, dms(tags.gps.lat)));
|
||||
gpsEntries.push(ascii(0x0003, tags.gps.lng < 0 ? 'W' : 'E'));
|
||||
gpsEntries.push(rationals(0x0004, dms(tags.gps.lng)));
|
||||
}
|
||||
|
||||
// IFD0 holds only the two pointers, so its size is known before the offsets are.
|
||||
const ifd0Count = (exifEntries.length ? 1 : 0) + (gpsEntries.length ? 1 : 0);
|
||||
const ifd0At = 8;
|
||||
const exifAt = ifd0At + 2 + ifd0Count * 12 + 4;
|
||||
const gpsAt = exifAt + (exifEntries.length ? ifdSize(exifEntries) : 0);
|
||||
const ifd0Entries: Entry[] = [];
|
||||
if (exifEntries.length) ifd0Entries.push(long(0x8769, exifAt));
|
||||
if (gpsEntries.length) ifd0Entries.push(long(0x8825, gpsAt));
|
||||
|
||||
const tiffHeader = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]);
|
||||
const tiff = Buffer.concat([
|
||||
tiffHeader,
|
||||
writeIfd(ifd0Entries, ifd0At),
|
||||
exifEntries.length ? writeIfd(exifEntries, exifAt) : Buffer.alloc(0),
|
||||
gpsEntries.length ? writeIfd(gpsEntries, gpsAt) : Buffer.alloc(0),
|
||||
]);
|
||||
const payload = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]);
|
||||
const app1 = Buffer.alloc(4);
|
||||
app1.writeUInt16BE(0xffe1, 0);
|
||||
app1.writeUInt16BE(payload.length + 2, 2);
|
||||
|
||||
const base = fs.readFileSync(BASE_JPEG);
|
||||
// SOI, then skip the JFIF APP0 (marker + its big-endian length).
|
||||
const app0Length = base.readUInt16BE(4);
|
||||
const rest = base.subarray(4 + app0Length);
|
||||
return Buffer.concat([base.subarray(0, 2), app1, payload, rest]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Journey API integration tests.
|
||||
* Covers JOURNEY-INT-001 through JOURNEY-INT-020.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
addJourneyContributor,
|
||||
} from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { jpegWithExif } from '../helpers/exif-jpeg';
|
||||
import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
@@ -1223,4 +1224,90 @@ describe('Journey upload parity', () => {
|
||||
const diskName = res.body.cover_image.replace(/^journey\//, '');
|
||||
expect(fsMod.existsSync(pathMod.join(journeyDir, diskName))).toBe(true);
|
||||
});
|
||||
|
||||
describe('capture metadata read from the uploaded file (#2512)', () => {
|
||||
// A server whose zone is neither UTC nor the photographer's: the setup the
|
||||
// report came from, and the only one where a stamp read in the wrong zone shows.
|
||||
let prevTz: string | undefined;
|
||||
beforeEach(() => {
|
||||
prevTz = process.env.TZ;
|
||||
process.env.TZ = 'America/Chicago';
|
||||
});
|
||||
afterEach(() => {
|
||||
if (prevTz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = prevTz;
|
||||
});
|
||||
|
||||
const phonePhoto = () => jpegWithExif({
|
||||
DateTimeOriginal: '2026:05:30 15:52:19',
|
||||
CreateDate: '2026:05:30 15:52:19',
|
||||
OffsetTime: '+02:00',
|
||||
OffsetTimeOriginal: '+02:00',
|
||||
OffsetTimeDigitized: '+02:00',
|
||||
gps: { lat: 49.274523, lng: -0.703421 },
|
||||
});
|
||||
|
||||
type CaptureRow = { taken_at: string | null; lat: number | null; lng: number | null };
|
||||
// The backfill runs detached after the response, so wait for it to land.
|
||||
async function captureOf(filePath: string): Promise<CaptureRow> {
|
||||
let row: CaptureRow | undefined;
|
||||
await vi.waitFor(() => {
|
||||
row = testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE file_path = ?').get(filePath) as CaptureRow;
|
||||
expect(row?.taken_at).toBeTruthy();
|
||||
});
|
||||
return row!;
|
||||
}
|
||||
|
||||
it('JOURNEY-P10: an entry photo stores the instant from DateTimeOriginal plus its offset, and its GPS', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-05-30' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('photos', phonePhoto(), { filename: 'PXL_20260530_135219.jpg', contentType: 'image/jpeg' });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = await captureOf(res.body.photos[0].file_path);
|
||||
// Not 2026-05-30T20:52:19.000Z, which is the stamp read as Chicago time.
|
||||
expect(row.taken_at).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(row.lat).toBeCloseTo(49.274523, 5);
|
||||
expect(row.lng).toBeCloseTo(-0.703421, 5);
|
||||
});
|
||||
|
||||
it('JOURNEY-P11: a gallery photo gets the same', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/gallery/photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('photos', phonePhoto(), { filename: 'PXL_20260530_135219.jpg', contentType: 'image/jpeg' });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = await captureOf(res.body.photos[0].file_path);
|
||||
expect(row.taken_at).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(row.lat).toBeCloseTo(49.274523, 5);
|
||||
expect(row.lng).toBeCloseTo(-0.703421, 5);
|
||||
});
|
||||
|
||||
it('JOURNEY-P12: zeros in the GPS block, as a receiver without a fix writes them, put no pin at 0,0', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/gallery/photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('photos', jpegWithExif({
|
||||
DateTimeOriginal: '2026:05:30 15:52:19',
|
||||
OffsetTimeOriginal: '+02:00',
|
||||
gps: { lat: 0, lng: 0 },
|
||||
}), { filename: 'nofix.jpg', contentType: 'image/jpeg' });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = await captureOf(res.body.photos[0].file_path);
|
||||
expect(row).toEqual({ taken_at: '2026-05-30T13:52:19.000Z', lat: null, lng: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
* headings in the picker mean something, so it is pinned here rather than
|
||||
* inside either provider suite.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
dayStartEpochSeconds,
|
||||
describeFetchFailure,
|
||||
exifCaptureInstant,
|
||||
isWithinLocalDayRange,
|
||||
shiftCalendarDay,
|
||||
sortAssetsByTakenAtDesc,
|
||||
@@ -181,3 +182,149 @@ describe('describeFetchFailure', () => {
|
||||
expect(describeFetchFailure(new TypeError('fetch failed', { cause }))).toBe('fetch failed (self-signed certificate)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exifCaptureInstant (#2512)', () => {
|
||||
// Pinned to a zone that is neither UTC nor the photographer's, because that
|
||||
// is the only setup where reading the stamp in the server's zone shows.
|
||||
let prevTz: string | undefined;
|
||||
beforeEach(() => {
|
||||
prevTz = process.env.TZ;
|
||||
process.env.TZ = 'America/Chicago';
|
||||
});
|
||||
afterEach(() => {
|
||||
if (prevTz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = prevTz;
|
||||
});
|
||||
|
||||
it('MEM-EXIF-001: applies the offset, so the stamp becomes the instant it names', () => {
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['-07:00'])).toBe('2026-05-30T22:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['+05:45'])).toBe('2026-05-30T10:07:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['+00:00'])).toBe('2026-05-30T15:52:19.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-002: an evening photo east of UTC stays on its own day', () => {
|
||||
expect(exifCaptureInstant('2026:05:30 22:30:00', ['+02:00'])).toBe('2026-05-30T20:30:00.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-003: takes the first offset that is well formed', () => {
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', [undefined, ' : ', '+2:00', '+09:00', '+02:00']))
|
||||
.toBe('2026-05-30T06:52:19.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-004: accepts the widest real zones and refuses what is past them', () => {
|
||||
expect(exifCaptureInstant('2026:05:30 12:00:00', ['+14:00'])).toBe('2026-05-29T22:00:00.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 12:00:00', ['-12:00'])).toBe('2026-05-31T00:00:00.000Z');
|
||||
// Out of range falls through to the server zone, like no offset at all.
|
||||
const local = exifCaptureInstant('2026:05:30 12:00:00', []);
|
||||
expect(exifCaptureInstant('2026:05:30 12:00:00', ['+15:00'])).toBe(local);
|
||||
expect(exifCaptureInstant('2026:05:30 12:00:00', ['+02:60'])).toBe(local);
|
||||
});
|
||||
|
||||
it('MEM-EXIF-005: without an offset the stamp is read in the server zone, as before', () => {
|
||||
// 15:52 CDT is 20:52 UTC.
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', [])).toBe('2026-05-30T20:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', [null, 42, '0200', '+02:00:00', '+2', 'CEST'])).toBe('2026-05-30T20:52:19.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-006: a wall clock inside a DST gap is still a date, not garbage', () => {
|
||||
// 02:30 on 8 March 2026 does not exist in Chicago; the old reader moved it an
|
||||
// hour on and so does this one. What matters is that it is not dropped.
|
||||
expect(exifCaptureInstant('2026:03:08 02:30:00', [])).toBe(new Date(2026, 2, 8, 2, 30, 0).toISOString());
|
||||
expect(exifCaptureInstant('2026:03:08 02:30:00', ['+01:00'])).toBe('2026-03-08T01:30:00.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-007: tolerates dashes, a T and fractional seconds', () => {
|
||||
expect(exifCaptureInstant('2026-05-30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30T15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19.123', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant(' 2026:05:30 15:52:19 ', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2028:02:29 08:00:00', ['+00:00'])).toBe('2028-02-29T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-008: a stamp that is not a real moment is null, not a rolled over date', () => {
|
||||
for (const stamp of [
|
||||
'0000:00:00 00:00:00', // camera without a clock
|
||||
' : : : : ', // blanked as the spec allows
|
||||
'2026:02:30 10:00:00',
|
||||
'2026:02:29 10:00:00',
|
||||
'2026:13:01 10:00:00',
|
||||
'2026:05:30 24:00:00',
|
||||
'2026:05:30 15:60:00',
|
||||
'2026:05:30 15:52:60',
|
||||
'0099:05:30 15:52:19', // Date would read this as 1999
|
||||
'2026:05:30',
|
||||
'2026:05:30 15:52',
|
||||
'yesterday',
|
||||
'',
|
||||
]) {
|
||||
expect(exifCaptureInstant(stamp, ['+02:00']), stamp).toBeNull();
|
||||
expect(exifCaptureInstant(stamp, []), stamp).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('MEM-EXIF-009: anything but a string is null', () => {
|
||||
expect(exifCaptureInstant(undefined, ['+02:00'])).toBeNull();
|
||||
expect(exifCaptureInstant(null, [])).toBeNull();
|
||||
expect(exifCaptureInstant(new Date('2026-05-30T13:52:19Z'), [])).toBeNull();
|
||||
expect(exifCaptureInstant(1780149139, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('MEM-EXIF-010: an offset tag written without the colon, or as Z or UTC, says the same thing', () => {
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['+0200'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['-0330'])).toBe('2026-05-30T19:22:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['Z'])).toBe('2026-05-30T15:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['utc'])).toBe('2026-05-30T15:52:19.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-011: a zone written onto the stamp itself is honoured, and wins over the offset tags', () => {
|
||||
// exifr's reviver names this form, and read it as Chicago time: 20:52:19Z.
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19 UTC', [])).toBe('2026-05-30T15:52:19.000Z');
|
||||
expect(exifCaptureInstant('2009-09-23 17:40:52 UTC', [])).toBe('2009-09-23T17:40:52.000Z');
|
||||
// The reviver turned these into local midnight of the day.
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19Z', [])).toBe('2026-05-30T15:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19+02:00', [])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026-05-30T15:52:19.250-07:00', [])).toBe('2026-05-30T22:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19 +0200', [])).toBe('2026-05-30T13:52:19.000Z');
|
||||
// The stamp's own zone is the more specific answer.
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19+02:00', ['+09:00'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19 UTC', ['+02:00'])).toBe('2026-05-30T15:52:19.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-012: a zone on the stamp that is out of range leaves the tags to decide', () => {
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19+15:00', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19+15:00', [])).toBe('2026-05-30T20:52:19.000Z');
|
||||
});
|
||||
|
||||
it('MEM-EXIF-014: a field written with one digit, and a GMT zone, still read as the old reviver read them', () => {
|
||||
expect(exifCaptureInstant('2026:5:30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 5:52:19', ['+02:00'])).toBe('2026-05-30T03:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19 GMT', ['+02:00'])).toBe('2026-05-30T15:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19', ['GMT'])).toBe('2026-05-30T15:52:19.000Z');
|
||||
// One digit is not a licence for three.
|
||||
expect(exifCaptureInstant('2026:005:30 15:52:19', ['+02:00'])).toBeNull();
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:190', ['+02:00'])).toBeNull();
|
||||
});
|
||||
|
||||
it('MEM-EXIF-013: anything else after the time is still not a stamp', () => {
|
||||
for (const stamp of ['2026:05:30 15:52:19 CEST', '2026:05:30 15:52:19 +2', '2026:05:30 15:52:19 UTC+2', '2026:05:30 15:52:19 later']) {
|
||||
expect(exifCaptureInstant(stamp, ['+02:00']), stamp).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('MEM-EXIF-015: a huge stamp from a hostile upload is turned down at once, not backtracked over', () => {
|
||||
// A run of digits after the seconds, then a line break: a greedy tail in
|
||||
// front of an end anchor tries every split of it. 200k of them took 20 s.
|
||||
const hostile = '2026:05:30 15:52:19.' + '1'.repeat(200_000) + '\nx';
|
||||
const started = performance.now();
|
||||
const read = exifCaptureInstant(hostile, ['+02:00']);
|
||||
const withHostileOffset = exifCaptureInstant('2026:05:30 15:52:19', [hostile]);
|
||||
expect(performance.now() - started).toBeLessThan(250);
|
||||
expect(read).toBeNull();
|
||||
expect(withHostileOffset).toBe('2026-05-30T20:52:19.000Z');
|
||||
// Up to the limit a stamp still reads, and a line break is no zone.
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19.' + '1'.repeat(38) + '+02:00', [])).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19.' + '1'.repeat(39) + '+02:00', [])).toBeNull();
|
||||
expect(exifCaptureInstant('2026:05:30 15:52:19\nx', ['+02:00'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* PhotoCaptureBackfillService against real files (#2512).
|
||||
*
|
||||
* The sibling suite mocks exifr, which is how a pick that could never return GPS
|
||||
* and a date read in the wrong zone both passed it: the mock handed back exactly
|
||||
* what the code hoped exifr would. Here exifr reads real JPEG bytes carrying the
|
||||
* tags a phone writes, through a real StorageService, with the process in a zone
|
||||
* that is neither UTC nor the photographer's.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
|
||||
import type { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
|
||||
import type { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
import { jpegWithExif, type ExifJpegTags } from '../../helpers/exif-jpeg';
|
||||
import { makeStorageFixture, type StorageFixture } from '../../helpers/storage-fixture';
|
||||
|
||||
// Normandy, west of Greenwich, so a dropped sign would show.
|
||||
const NORMANDY = { lat: 49.274523, lng: -0.703421 };
|
||||
|
||||
let fx: StorageFixture;
|
||||
let prevTz: string | undefined;
|
||||
let seq = 0;
|
||||
|
||||
beforeAll(() => { fx = makeStorageFixture('journey/'); });
|
||||
afterAll(() => fx.cleanup());
|
||||
beforeEach(() => {
|
||||
prevTz = process.env.TZ;
|
||||
process.env.TZ = 'America/Chicago';
|
||||
});
|
||||
afterEach(() => {
|
||||
if (prevTz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = prevTz;
|
||||
});
|
||||
|
||||
/** Stores `bytes` as a local journey upload, runs the backfill on it, returns what was recorded. */
|
||||
async function backfill(bytes: Buffer): Promise<unknown[][]> {
|
||||
const name = `upload-${++seq}.jpg`;
|
||||
await fx.storage.put('journey', name, Readable.from(bytes), { contentType: 'image/jpeg' });
|
||||
const recordCaptureMetadata = vi.fn();
|
||||
const photos = {
|
||||
resolve: (id: number) => ({ id, provider: 'local', file_path: `journey/${name}`, taken_at: null, lat: null, lng: null }),
|
||||
recordCaptureMetadata,
|
||||
} as unknown as TrekPhotosRepository;
|
||||
const svc = new PhotoCaptureBackfillService({} as PhotoResolverService, photos, fx.storage);
|
||||
await svc.run([7], 1);
|
||||
return recordCaptureMetadata.mock.calls;
|
||||
}
|
||||
|
||||
const photo = (tags: ExifJpegTags) => backfill(jpegWithExif(tags));
|
||||
|
||||
describe('PhotoCaptureBackfillService reading real EXIF', () => {
|
||||
it('EXIF-001: the reported phone photo gets its true instant and its GPS', async () => {
|
||||
const calls = await photo({
|
||||
DateTimeOriginal: '2026:05:30 15:52:19',
|
||||
CreateDate: '2026:05:30 15:52:19',
|
||||
OffsetTime: '+02:00',
|
||||
OffsetTimeOriginal: '+02:00',
|
||||
OffsetTimeDigitized: '+02:00',
|
||||
gps: NORMANDY,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
const [id, meta] = calls[0] as [number, { takenAt: string; lat: number; lng: number }];
|
||||
expect(id).toBe(7);
|
||||
// Not 20:52:19Z, which is 15:52 read as Chicago time.
|
||||
expect(meta.takenAt).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(meta.lat).toBeCloseTo(NORMANDY.lat, 5);
|
||||
expect(meta.lng).toBeCloseTo(NORMANDY.lng, 5);
|
||||
});
|
||||
|
||||
it('EXIF-002: the offset is found in OffsetTimeDigitized when it is the only one', async () => {
|
||||
const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeDigitized: '+02:00' });
|
||||
expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]);
|
||||
});
|
||||
|
||||
it('EXIF-003: the offset is found in OffsetTime when it is the only one', async () => {
|
||||
const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTime: '+09:00' });
|
||||
expect(calls).toEqual([[7, { takenAt: '2026-05-30T06:52:19.000Z', lat: null, lng: null }]]);
|
||||
});
|
||||
|
||||
it('EXIF-004: DateTimeOriginal goes with its own offset when the tags disagree', async () => {
|
||||
const calls = await photo({
|
||||
DateTimeOriginal: '2026:05:30 15:52:19',
|
||||
OffsetTimeOriginal: '+02:00',
|
||||
OffsetTimeDigitized: '+05:00',
|
||||
OffsetTime: '-03:00',
|
||||
});
|
||||
expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]);
|
||||
});
|
||||
|
||||
it('EXIF-005: CreateDate stands in for a missing DateTimeOriginal, with its own offset first', async () => {
|
||||
const calls = await photo({
|
||||
CreateDate: '2026:05:30 21:10:00',
|
||||
OffsetTimeDigitized: '+01:00',
|
||||
OffsetTimeOriginal: '+02:00',
|
||||
});
|
||||
expect(calls).toEqual([[7, { takenAt: '2026-05-30T20:10:00.000Z', lat: null, lng: null }]]);
|
||||
});
|
||||
|
||||
it('EXIF-006: CreateDate also stands in when DateTimeOriginal is garbage', async () => {
|
||||
const calls = await photo({
|
||||
DateTimeOriginal: '0000:00:00 00:00:00',
|
||||
CreateDate: '2026:05:30 15:52:19',
|
||||
OffsetTimeOriginal: '+02:00',
|
||||
});
|
||||
expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]);
|
||||
});
|
||||
|
||||
it('EXIF-007: with no offset the stamp is read in the server zone, exactly as before', async () => {
|
||||
const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19' });
|
||||
// 15:52 CDT.
|
||||
expect(calls).toEqual([[7, { takenAt: '2026-05-30T20:52:19.000Z', lat: null, lng: null }]]);
|
||||
});
|
||||
|
||||
it('EXIF-008: a malformed offset is ignored like a missing one', async () => {
|
||||
for (const bad of ['+2:00', ' : ', '+99:00', 'CEST']) {
|
||||
const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeOriginal: bad });
|
||||
expect(calls, bad).toEqual([[7, { takenAt: '2026-05-30T20:52:19.000Z', lat: null, lng: null }]]);
|
||||
}
|
||||
});
|
||||
|
||||
it('EXIF-009: an impossible or garbage date is not stored, and does not cost the location', async () => {
|
||||
for (const bad of ['0000:00:00 00:00:00', '2026:02:30 25:61:00', ' : : : : ', 'not a date']) {
|
||||
const calls = await photo({ DateTimeOriginal: bad, OffsetTimeOriginal: '+02:00', gps: NORMANDY });
|
||||
expect(calls, bad).toHaveLength(1);
|
||||
const [, meta] = calls[0] as [number, { takenAt: string | null; lat: number; lng: number }];
|
||||
expect(meta.takenAt, bad).toBeNull();
|
||||
expect(meta.lat, bad).toBeCloseTo(NORMANDY.lat, 5);
|
||||
expect(meta.lng, bad).toBeCloseTo(NORMANDY.lng, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it('EXIF-010: an impossible date with nothing else to go on records nothing', async () => {
|
||||
expect(await photo({ DateTimeOriginal: '2026:02:30 10:00:00' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('EXIF-011: GPS without any date still puts the photo on the map', async () => {
|
||||
const calls = await photo({ gps: { lat: -33.856784, lng: 151.215297 } });
|
||||
expect(calls).toHaveLength(1);
|
||||
const [, meta] = calls[0] as [number, { takenAt: string | null; lat: number; lng: number }];
|
||||
expect(meta.takenAt).toBeNull();
|
||||
expect(meta.lat).toBeCloseTo(-33.856784, 5);
|
||||
expect(meta.lng).toBeCloseTo(151.215297, 5);
|
||||
});
|
||||
|
||||
it('EXIF-012: a JPEG without EXIF and a file that is not an image both record nothing', async () => {
|
||||
const plain = fs.readFileSync(path.join(__dirname, '../../fixtures/small-image.jpg'));
|
||||
expect(await backfill(plain)).toEqual([]);
|
||||
expect(await backfill(Buffer.from('definitely not a jpeg'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('EXIF-013: a GPS block of zeros, as a receiver without a fix writes it, is no location', async () => {
|
||||
const zeros = { lat: 0, lng: 0 };
|
||||
expect(await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeOriginal: '+02:00', gps: zeros }))
|
||||
.toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]);
|
||||
// Nothing else to go on: nothing is recorded, rather than a pin in the Gulf of Guinea.
|
||||
expect(await photo({ gps: zeros })).toEqual([]);
|
||||
});
|
||||
|
||||
it('EXIF-014: a real place on the equator or the prime meridian keeps its location', async () => {
|
||||
for (const gps of [{ lat: 0, lng: 32.58 }, { lat: 51.4779, lng: 0 }]) {
|
||||
const calls = await photo({ gps });
|
||||
expect(calls, JSON.stringify(gps)).toHaveLength(1);
|
||||
const [, meta] = calls[0] as [number, { lat: number; lng: number }];
|
||||
expect(meta.lat).toBeCloseTo(gps.lat, 5);
|
||||
expect(meta.lng).toBeCloseTo(gps.lng, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it('EXIF-015: a stamp that carries its own zone is read in that zone', async () => {
|
||||
const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19 UTC', OffsetTimeOriginal: '+02:00' });
|
||||
expect(calls).toEqual([[7, { takenAt: '2026-05-30T15:52:19.000Z', lat: null, lng: null }]]);
|
||||
});
|
||||
});
|
||||
@@ -131,8 +131,10 @@ describe('PhotoCaptureBackfillService — local files', () => {
|
||||
}
|
||||
|
||||
it('CAPTURE-007: reads a local file rather than asking a provider', async () => {
|
||||
// Raw values, as exifr hands them over with reviveValues off (#2512).
|
||||
vi.mocked(exifr.parse).mockResolvedValue({
|
||||
DateTimeOriginal: new Date('2026-03-15T10:20:00Z'),
|
||||
DateTimeOriginal: '2026:03:15 11:20:00',
|
||||
OffsetTimeOriginal: '+01:00',
|
||||
latitude: 48.8584,
|
||||
longitude: 2.2945,
|
||||
});
|
||||
@@ -149,7 +151,7 @@ describe('PhotoCaptureBackfillService — local files', () => {
|
||||
});
|
||||
|
||||
it('CAPTURE-008: falls back to CreateDate when the original timestamp is missing', async () => {
|
||||
vi.mocked(exifr.parse).mockResolvedValue({ CreateDate: new Date('2026-03-16T08:00:00Z') });
|
||||
vi.mocked(exifr.parse).mockResolvedValue({ CreateDate: '2026:03:16 08:00:00', OffsetTimeDigitized: '+00:00' });
|
||||
const { svc, recordCaptureMetadata } = localBuild([
|
||||
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
||||
]);
|
||||
@@ -161,6 +163,35 @@ describe('PhotoCaptureBackfillService — local files', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('CAPTURE-016: a coordinate that is not a real one drops the pair, not the capture time', async () => {
|
||||
// 0,0 is what a receiver without a fix writes, not a place (#2512).
|
||||
for (const [latitude, longitude] of [[Number.NaN, 2.2945], [48.8584, 200], [91, 2.2945], [48.8584, undefined], [0, 0]]) {
|
||||
vi.mocked(exifr.parse).mockResolvedValue({
|
||||
DateTimeOriginal: '2026:03:15 11:20:00', OffsetTimeOriginal: '+01:00', latitude, longitude,
|
||||
});
|
||||
const { svc, recordCaptureMetadata } = localBuild([
|
||||
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
||||
]);
|
||||
|
||||
await svc.run([7], 1);
|
||||
|
||||
expect(recordCaptureMetadata).toHaveBeenCalledWith(7, {
|
||||
takenAt: '2026-03-15T10:20:00.000Z', lat: null, lng: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('CAPTURE-017: a bad coordinate with no usable date records nothing', async () => {
|
||||
vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: 'garbage', latitude: 48.8584 });
|
||||
const { svc, recordCaptureMetadata } = localBuild([
|
||||
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
||||
]);
|
||||
|
||||
await svc.run([7], 1);
|
||||
|
||||
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CAPTURE-009: a file with nothing readable is left alone', async () => {
|
||||
vi.mocked(exifr.parse).mockResolvedValue({});
|
||||
const { svc, recordCaptureMetadata } = localBuild([
|
||||
@@ -224,7 +255,7 @@ describe('PhotoCaptureBackfillService — local files', () => {
|
||||
});
|
||||
|
||||
it('CAPTURE-015: schedule kicks the run off for a non-empty batch', async () => {
|
||||
vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: new Date('2026-03-15T10:20:00Z') });
|
||||
vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: '2026:03:15 10:20:00', OffsetTimeOriginal: '+00:00' });
|
||||
const { svc, recordCaptureMetadata } = localBuild([
|
||||
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
||||
]);
|
||||
|
||||
@@ -51,7 +51,7 @@ function makeFixture(keyPrefix = 'files/'): Fixture {
|
||||
}
|
||||
|
||||
/** A driver with no getLocalPath — the remote-driver branch every helper must handle. */
|
||||
function makeStreamOnlyFixture(contents: string): Fixture & { driverCalls: string[] } {
|
||||
function makeStreamOnlyFixture(contents: string, body = (bytes: Buffer): Readable => Readable.from(bytes)): Fixture & { driverCalls: string[] } {
|
||||
const fx = makeFixture('');
|
||||
const driverCalls: string[] = [];
|
||||
const bytes = Buffer.from(contents);
|
||||
@@ -61,7 +61,7 @@ function makeStreamOnlyFixture(contents: string): Fixture & { driverCalls: strin
|
||||
put: async () => undefined,
|
||||
getStream: async (key: string) => {
|
||||
driverCalls.push(`getStream:${key}`);
|
||||
return { stream: Readable.from(bytes), stat: { ...stat, key } };
|
||||
return { stream: body(bytes), stat: { ...stat, key } };
|
||||
},
|
||||
stat: async (key: string) => ({ ...stat, key }),
|
||||
delete: async () => undefined,
|
||||
@@ -216,6 +216,22 @@ describe('StorageService withLocalFile', () => {
|
||||
expect(fs.existsSync(seenPath)).toBe(false); // cleaned up on throw too
|
||||
});
|
||||
|
||||
it('leaves nothing in tempDir when the download itself fails midway', async () => {
|
||||
const cut = new Error('socket hang up');
|
||||
const remote = makeStreamOnlyFixture('first half of the bytes', bytes =>
|
||||
Readable.from((async function* () {
|
||||
yield bytes;
|
||||
throw cut;
|
||||
})()),
|
||||
);
|
||||
let called = false;
|
||||
|
||||
await expect(remote.storage.withLocalFile('files', 'remote.bin', async () => { called = true; })).rejects.toBe(cut);
|
||||
|
||||
expect(called).toBe(false);
|
||||
expect(fs.readdirSync(remote.tempDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls through to the stream branch when getLocalPath resolves but the file is not actually on disk', async () => {
|
||||
// Composite-driver stand-in (task C9-i): the façade must fs-check the
|
||||
// local path itself rather than trust driver.stat() as "the file is at
|
||||
|
||||
Reference in New Issue
Block a user