mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
A API do Flickr responde raw/clean como { _content } e o mapper só aceitava
string — as 6.492 fotos do backfill gravaram exif: []. archivePhotoExifEntries
desembrulha os dois shapes (contentScalar também substitui o unwrap ad-hoc do
description) e o CLI ganha o modo --refresh-metadata, que re-busca o EXIF das
linhas existentes e atualiza só o que mudou: write-only no campo exif, sem S3,
guardas de confirmação/TEQO_ENV e recibo atualizadas/inalteradas/falhas.
Runbook §C231 documenta a operação. Closes #1383.
278 lines
9.7 KiB
TypeScript
278 lines
9.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
archivePhotoAlt,
|
|
archivePhotoExifEntries,
|
|
archivePhotoGeoFrom,
|
|
archivePhotoImportFromFlickr,
|
|
archivePhotoPostedAt,
|
|
archivePhotoSourceUrl,
|
|
archivePhotoStorageFilename,
|
|
archivePhotoTags,
|
|
archivePhotoTakenAt,
|
|
} from '@/lib/archivePhoto'
|
|
|
|
// C231 — the pure rules of the Flickr archive mapping: the original naming,
|
|
// the metadata normalization (tags/geo/EXIF/dates) and the three named
|
|
// rejection reasons. Real API payloads never enter the suite.
|
|
|
|
const listingPhoto = (overrides: Record<string, unknown> = {}) => ({
|
|
id: '53123456789',
|
|
owner: '12345678@N00',
|
|
title: 'Comício em Feira de Santana',
|
|
description: { _content: 'Registro do comício.' },
|
|
datetaken: '2025-03-14 10:20:30',
|
|
dateupload: '1742032800',
|
|
license: '0',
|
|
tags: 'saude salvador saude',
|
|
latitude: '-12.2664',
|
|
longitude: '-38.9663',
|
|
media: 'photo',
|
|
url_o: 'https://live.staticflickr.com/65535/53123456789_abcdef_o.jpg',
|
|
path_alias: 'depjorgesolla',
|
|
...overrides,
|
|
})
|
|
|
|
describe('archivePhotoStorageFilename (C231)', () => {
|
|
it('derives the deterministic name from the original URL', () => {
|
|
expect(
|
|
archivePhotoStorageFilename('53123456789', 'https://live.staticflickr.com/1/2_o.JPG'),
|
|
).toBe('flickr-53123456789.jpg')
|
|
expect(
|
|
archivePhotoStorageFilename('53123456789', 'https://live.staticflickr.com/1/2_o.jpeg'),
|
|
).toBe('flickr-53123456789.jpg')
|
|
expect(
|
|
archivePhotoStorageFilename('53123456789', 'https://live.staticflickr.com/1/2_o.png'),
|
|
).toBe('flickr-53123456789.png')
|
|
})
|
|
|
|
it('falls back to .jpg for an extensionless or malformed URL', () => {
|
|
expect(archivePhotoStorageFilename('42', 'https://live.staticflickr.com/1/2_o')).toBe(
|
|
'flickr-42.jpg',
|
|
)
|
|
expect(archivePhotoStorageFilename('42', 'not a url')).toBe('flickr-42.jpg')
|
|
})
|
|
|
|
it('keeps only filename-safe id characters', () => {
|
|
expect(archivePhotoStorageFilename('53/../evil', 'https://x/1_o.jpg')).toBe('flickr-53evil.jpg')
|
|
})
|
|
})
|
|
|
|
describe('archivePhotoAlt (C231)', () => {
|
|
it('prefers the Flickr title and falls back to the id', () => {
|
|
expect(archivePhotoAlt({ title: ' Comício ', flickrId: '1' })).toBe('Comício')
|
|
expect(archivePhotoAlt({ title: ' ', flickrId: '1' })).toBe('Foto do acervo (Flickr 1)')
|
|
expect(archivePhotoAlt({ title: null, flickrId: '1' })).toBe('Foto do acervo (Flickr 1)')
|
|
})
|
|
})
|
|
|
|
describe('archivePhotoSourceUrl (C231)', () => {
|
|
it('uses the path alias when present and the id-only page when absent', () => {
|
|
expect(archivePhotoSourceUrl({ flickrId: '42', pathAlias: 'depjorgesolla' })).toBe(
|
|
'https://www.flickr.com/photos/depjorgesolla/42/',
|
|
)
|
|
expect(archivePhotoSourceUrl({ flickrId: '42', pathAlias: null })).toBe(
|
|
'https://www.flickr.com/photo.gne?id=42',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('archivePhotoTakenAt / archivePhotoPostedAt (C231)', () => {
|
|
it('keeps the zoneless wall clock, completing a date-only value', () => {
|
|
expect(archivePhotoTakenAt('2025-03-14 10:20:30')).toBe('2025-03-14 10:20:30')
|
|
expect(archivePhotoTakenAt('2025-03-14')).toBe('2025-03-14 00:00:00')
|
|
expect(archivePhotoTakenAt('14/03/2025')).toBeNull()
|
|
expect(archivePhotoTakenAt(undefined)).toBeNull()
|
|
})
|
|
|
|
it('derives the ISO UTC upload time from epoch seconds', () => {
|
|
expect(archivePhotoPostedAt('1742032800')).toBe(new Date(1742032800_000).toISOString())
|
|
expect(archivePhotoPostedAt(1742032800)).toBe(new Date(1742032800_000).toISOString())
|
|
expect(archivePhotoPostedAt('0')).toBeNull()
|
|
expect(archivePhotoPostedAt('ontem')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('archivePhotoTags (C231)', () => {
|
|
it('splits the space-separated listing value and collapses duplicates', () => {
|
|
expect(archivePhotoTags('saude salvador saude bahia')).toEqual(['saude', 'salvador', 'bahia'])
|
|
expect(archivePhotoTags(['saude bahia', 'saude'])).toEqual(['saude', 'bahia'])
|
|
expect(archivePhotoTags('')).toEqual([])
|
|
expect(archivePhotoTags(null)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('archivePhotoGeoFrom (C231)', () => {
|
|
it('accepts a real point and refuses the (0, 0) sentinel or out-of-range values', () => {
|
|
expect(archivePhotoGeoFrom('-12.2664', '-38.9663')).toEqual({
|
|
latitude: -12.2664,
|
|
longitude: -38.9663,
|
|
})
|
|
expect(archivePhotoGeoFrom(0, 0)).toBeNull()
|
|
expect(archivePhotoGeoFrom('91', '0.5')).toBeNull()
|
|
expect(archivePhotoGeoFrom('-12.5', '181')).toBeNull()
|
|
expect(archivePhotoGeoFrom('', undefined)).toBeNull()
|
|
})
|
|
|
|
it('never returns half a point when one axis is missing', () => {
|
|
expect(archivePhotoGeoFrom('-12.2664', undefined)).toBeNull()
|
|
expect(archivePhotoGeoFrom(undefined, '-38.9663')).toBeNull()
|
|
expect(archivePhotoGeoFrom('-12.2664', '')).toBeNull()
|
|
expect(archivePhotoGeoFrom('', '-38.9663')).toBeNull()
|
|
expect(archivePhotoGeoFrom(null, null)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('archivePhotoExifEntries (C231)', () => {
|
|
it('normalizes the API answer, preferring clean values and sorting by tag', () => {
|
|
expect(
|
|
archivePhotoExifEntries([
|
|
{ tag: 'Model', label: 'Modelo', raw: 'Canon EOS', clean: 'Canon EOS R6' },
|
|
{ tag: 'Make', label: 'Fabricante', raw: 'Canon' },
|
|
{ tag: 'Empty', raw: ' ' },
|
|
{ tag: 'NoValue' },
|
|
'lixo',
|
|
]),
|
|
).toEqual([
|
|
{ tag: 'Make', label: 'Fabricante', value: 'Canon' },
|
|
{ tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' },
|
|
])
|
|
expect(archivePhotoExifEntries(null)).toEqual([])
|
|
})
|
|
|
|
it("unwraps the JSON API's { _content } leaves and still accepts plain strings", () => {
|
|
expect(
|
|
archivePhotoExifEntries([
|
|
{ tag: 'Make', label: 'Fabricante', raw: { _content: 'Canon' } },
|
|
{
|
|
tag: 'Model',
|
|
label: 'Modelo',
|
|
raw: { _content: 'Canon EOS' },
|
|
clean: { _content: 'Canon EOS R6' },
|
|
},
|
|
{ tag: 'Artist', label: 'Autor', raw: { _content: ' ' } },
|
|
{ tag: 'Unknown', label: 'Desconhecido', raw: { outro: 'x' } },
|
|
{ tag: 'Software', label: 'Software', clean: 'Tezza' },
|
|
]),
|
|
).toEqual([
|
|
{ tag: 'Make', label: 'Fabricante', value: 'Canon' },
|
|
{ tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' },
|
|
{ tag: 'Software', label: 'Software', value: 'Tezza' },
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('archivePhotoImportFromFlickr (C231)', () => {
|
|
it('maps a complete listing item into the archive record', () => {
|
|
const result = archivePhotoImportFromFlickr({
|
|
photo: listingPhoto(),
|
|
albums: [{ albumId: '721777', title: 'Saúde' }],
|
|
exif: [{ tag: 'Make', label: 'Fabricante', raw: 'Canon' }],
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.record).toEqual({
|
|
flickrId: '53123456789',
|
|
sourceUrl: 'https://www.flickr.com/photos/depjorgesolla/53123456789/',
|
|
owner: '12345678@N00',
|
|
license: '0',
|
|
title: 'Comício em Feira de Santana',
|
|
description: 'Registro do comício.',
|
|
tags: ['saude', 'salvador'],
|
|
takenAt: '2025-03-14 10:20:30',
|
|
postedAt: new Date(1742032800_000).toISOString(),
|
|
albums: [{ albumId: '721777', title: 'Saúde' }],
|
|
geo: { latitude: -12.2664, longitude: -38.9663 },
|
|
exif: [{ tag: 'Make', label: 'Fabricante', value: 'Canon' }],
|
|
originalUrl: 'https://live.staticflickr.com/65535/53123456789_abcdef_o.jpg',
|
|
originalKind: 'original',
|
|
})
|
|
})
|
|
|
|
it('falls back to the largest size and collapses albums deterministically', () => {
|
|
const result = archivePhotoImportFromFlickr({
|
|
photo: listingPhoto({ url_o: undefined }),
|
|
albums: [
|
|
{ albumId: 'b', title: 'Comícios' },
|
|
{ albumId: 'a', title: 'Saúde' },
|
|
{ albumId: 'b', title: 'Duplicado' },
|
|
],
|
|
exif: null,
|
|
fallbackOriginalUrl: 'https://live.staticflickr.com/1/2_b.jpg',
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.record.originalKind).toBe('largest')
|
|
expect(result.record.originalUrl).toBe('https://live.staticflickr.com/1/2_b.jpg')
|
|
expect(result.record.albums).toEqual([
|
|
{ albumId: 'b', title: 'Comícios' },
|
|
{ albumId: 'a', title: 'Saúde' },
|
|
])
|
|
})
|
|
|
|
it('rejects each failure with its own named reason', () => {
|
|
expect(
|
|
archivePhotoImportFromFlickr({ photo: listingPhoto({ id: 'abc' }), albums: [], exif: null }),
|
|
).toEqual({ ok: false, reason: 'id' })
|
|
expect(
|
|
archivePhotoImportFromFlickr({
|
|
photo: listingPhoto({ media: 'video' }),
|
|
albums: [],
|
|
exif: null,
|
|
}),
|
|
).toEqual({ ok: false, reason: 'not-a-photo' })
|
|
expect(
|
|
archivePhotoImportFromFlickr({
|
|
photo: listingPhoto({ url_o: undefined }),
|
|
albums: [],
|
|
exif: null,
|
|
}),
|
|
).toEqual({ ok: false, reason: 'original' })
|
|
})
|
|
|
|
it('tolerates absent optional metadata and a numeric license', () => {
|
|
const result = archivePhotoImportFromFlickr({
|
|
photo: listingPhoto({
|
|
title: '',
|
|
description: undefined,
|
|
tags: undefined,
|
|
datetaken: 'sem data',
|
|
dateupload: undefined,
|
|
latitude: '0',
|
|
longitude: '0',
|
|
license: 4,
|
|
path_alias: undefined,
|
|
}),
|
|
albums: [],
|
|
exif: undefined,
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.record).toMatchObject({
|
|
title: null,
|
|
description: null,
|
|
tags: [],
|
|
takenAt: null,
|
|
postedAt: null,
|
|
geo: null,
|
|
exif: [],
|
|
license: '4',
|
|
sourceUrl: 'https://www.flickr.com/photo.gne?id=53123456789',
|
|
})
|
|
})
|
|
|
|
it('rejects a non-scalar license instead of stringifying it', () => {
|
|
const result = archivePhotoImportFromFlickr({
|
|
photo: listingPhoto({ license: { id: 4 } }),
|
|
albums: [],
|
|
exif: null,
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.record.license).toBeNull()
|
|
})
|
|
})
|