import { decodeUtf8WithWarning, extractKmlPlacemarkNodes, kmlLineGeometry, KMZ_DECOMPRESSED_SIZE_LIMIT, parseKmlPointCoordinates, parsePlacemarkNode, readKmlDocument, readKmlLines, readKmzEntryCapped, sanitizeKmlDescription, unpackKmzToKml, } from '../../../../src/nest/place-import/kml.codec'; import { KmlReadError } from '../../../../src/nest/place-import/place-import.types'; import { buildCategoryNameLookup, resolveCategoryIdForFolder } from '../../../../src/nest/places/places.helpers'; import fs from 'fs'; import path from 'path'; import { PassThrough, Readable } from 'stream'; import unzipper, { type Entry } from 'unzipper'; import { describe, it, expect, vi } from 'vitest'; describe('kmlImportUtils', () => { it('sanitizes HTML descriptions with br to newline', () => { const input = 'Line 1
Line 2 & more'; const output = sanitizeKmlDescription(input); expect(output).toBe('Line 1\nLine 2 & more'); }); it('unwraps CDATA sections before stripping tags', () => { const input = 'for photos and skyline.]]>'; expect(sanitizeKmlDescription(input)).toBe('Great spot\nfor photos and skyline.'); }); it('parses KML coordinate order lng,lat,alt', () => { const parsed = parseKmlPointCoordinates('13.4050,52.5200,15'); expect(parsed).toEqual({ lat: 52.52, lng: 13.405 }); }); it('extracts placemarks from nested folders', () => { const root = { Document: { Folder: { name: 'Parent', Folder: { name: 'Child', Placemark: { name: 'Nested', Point: { coordinates: '13.4,52.5,0' } }, }, }, }, }; const nodes = extractKmlPlacemarkNodes(root); expect(nodes).toHaveLength(1); expect(nodes[0].folderName).toBe('Child'); const parsed = parsePlacemarkNode(nodes[0]); expect(parsed.name).toBe('Nested'); expect(parsed.lat).toBe(52.5); expect(parsed.lng).toBe(13.4); }); it('builds exact case-insensitive category lookup', () => { const lookup = buildCategoryNameLookup([ { id: 3, name: 'Museums' }, { id: 4, name: 'Parks' }, ]); expect(resolveCategoryIdForFolder('museums', lookup)).toBe(3); expect(resolveCategoryIdForFolder('Museum', lookup)).toBeNull(); expect(resolveCategoryIdForFolder('parks', lookup)).toBe(4); }); it('decodes non-BMP decimal HTML entities (emoji)', () => { // 😀 = U+1F600 = 😀 — requires String.fromCodePoint, not fromCharCode expect(sanitizeKmlDescription('😀')).toBe('😀'); }); it('decodes non-BMP hex HTML entities (emoji)', () => { // 😀 = U+1F600 = 😀 expect(sanitizeKmlDescription('😀')).toBe('😀'); }); it('does not produce [object Object] when description is a parsed object with #text', () => { // fast-xml-parser can return an object for mixed-content nodes when stopNodes // is not configured; the fallback in asTrimmedString must extract #text. const result = sanitizeKmlDescription({ '#text': 'Hello world' } as any); expect(result).not.toBe('[object Object]'); expect(result).toBe('Hello world'); }); it('returns null when description object has no #text', () => { expect(sanitizeKmlDescription({ i: 'bold' } as any)).toBeNull(); }); it('returns warning for non-UTF8 payload', () => { const buffer = Buffer.concat([ Buffer.from('Caf'), Buffer.from([0xe9]), Buffer.from(''), ]); const decoded = decodeUtf8WithWarning(buffer); expect(decoded.warning).toContain('not valid UTF-8'); expect(decoded.text).toContain(''); }); }); const KMZ_FIXTURE = path.join(__dirname, '../../../fixtures/test.kmz'); describe('unpackKmzToKml', () => { it('extracts the KML entry from a valid KMZ', async () => { const kmzBuffer = fs.readFileSync(KMZ_FIXTURE); const kmlBuffer = await unpackKmzToKml(kmzBuffer); expect(kmlBuffer.length).toBeGreaterThan(0); expect(kmlBuffer.toString('utf-8')).toContain(' { const kmzBuffer = fs.readFileSync(KMZ_FIXTURE); // test.kmz contains a KML with uncompressedSize 634 — set limit to 1 byte await expect(unpackKmzToKml(kmzBuffer, 1)).rejects.toThrow('exceeds the maximum allowed decompressed size'); }); it('rejects a KMZ that contains no KML file', async () => { // Craft a minimal ZIP containing only a non-KML entry using raw ZIP bytes // We use the test GPX fixture (a real file) re-zipped via Node's zlib/archiver // Simplest: a KMZ whose only file has a .txt extension const Archiver = await import('archiver'); const archiver = Archiver.default; const { PassThrough } = await import('stream'); const chunks: Buffer[] = []; const output = new PassThrough(); output.on('data', (chunk) => chunks.push(chunk)); const archive = archiver('zip', { zlib: { level: 1 } }); archive.pipe(output); archive.append(Buffer.from('not a kml'), { name: 'data.txt' }); await archive.finalize(); const zipBuffer = Buffer.concat(chunks); await expect(unpackKmzToKml(zipBuffer)).rejects.toThrow('does not contain a KML file'); }); it('rejects a buffer that is not a valid ZIP archive', async () => { await expect(unpackKmzToKml(Buffer.from('this is not a zip'))).rejects.toThrow('Invalid KMZ archive'); }); it('exports KMZ_DECOMPRESSED_SIZE_LIMIT as 50 MB', () => { expect(KMZ_DECOMPRESSED_SIZE_LIMIT).toBe(50 * 1024 * 1024); }); }); describe('readKmlDocument', () => { it('reads every placemark (a level before its folders) and counts them in the summary', () => { const kml = `Food Cafe13.4,52.5,0 Walk13.4,52.5 13.5,52.6 `; const read = readKmlDocument(Buffer.from(kml, 'utf-8')); expect(read.summary).toMatchObject({ totalPlacemarks: 2, createdCount: 0, skippedCount: 0, warnings: [], errors: [], }); expect(read.placemarks.map((p) => [p.name, p.folderName, p.routeGeometry !== null])).toEqual([ ['Walk', null, true], ['Cafe', 'Food', false], ]); }); it('refuses a document that is not well-formed XML with the route message', () => { expect(() => readKmlDocument(Buffer.from('', 'utf-8'))).toThrow( 'Malformed KML: invalid XML structure', ); }); it('keeps reading a file that is not valid UTF-8 and says so in the summary', () => { const bytes = Buffer.concat([ Buffer.from(''), Buffer.from([0xff]), Buffer.from(''), ]); expect(readKmlDocument(bytes).summary.warnings).toEqual([ 'The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.', ]); }); }); const kml = (body: string) => Buffer.from(`${body}`); /** An archive entry whose stream yields these chunks, or fails after them. */ const fakeEntry = (chunks: Buffer[], fail?: Error) => ({ stream: () => { const out = new PassThrough(); for (const chunk of chunks) out.write(chunk); if (fail) setImmediate(() => out.destroy(fail)); else out.end(); return out as unknown as Entry; }, }); describe('readKmlLines (IO-02)', () => { it('KML-LINES-001: reads every LineString and gx:Track in document order, each a line of its own', () => { const read = readKmlLines( kml(`CoastAlong the & sea 11,48 11.01,48.01 RecordedMorningabsolute 2026-01-01T08:00:00Z2026-01-01T08:05:00Z 12 49 40012.01 49.01 420.5`), ); expect(read.lines).toEqual([ { name: 'Coast', description: 'Along the & sea', geometry: [ [48, 11], [48.01, 11.01], ], }, { name: 'Morning', description: null, geometry: [ [49, 12, 400], [49.01, 12.01, 420.5], ], }, ]); expect(read.leftOut).toEqual({ points: 0, areas: 0, other: 0 }); expect(read.warnings).toEqual([]); }); it('KML-LINES-002: keeps the lines of one placemark apart and numbers them, never joins them', () => { const read = readKmlLines( kml(`Ferry day 11,48 11.01,48.01 11.5,48.5 11.51,48.51 11.5,48.5 absolute 12 49 112.1 49.1 2 13 50 313.1 50.1 4`), ); expect(read.lines.map((line) => [line.name, line.geometry.length, line.geometry[0]])).toEqual([ ['Ferry day (1/2)', 2, [48, 11]], ['Ferry day (2/2)', 2, [48.5, 11.5]], [null, 2, [49, 12, 1]], [null, 2, [50, 13, 3]], ]); expect(read.leftOut).toEqual({ points: 1, areas: 0, other: 0 }); }); it('KML-LINES-003: counts what it leaves out: pins, areas, models, overlays, links, empty and short ones', () => { const read = readKmlLines( kml(`Hut11,48 1,1 2,2 3,1 1,1 house.dae Nothing 11,48 12 49 1 https://example.test/more.kml map.png`), ); expect(read.lines).toEqual([]); // other: the model, the empty placemark, the short line, the short track, the link and two overlays. expect(read.leftOut).toEqual({ points: 1, areas: 1, other: 7 }); }); it('KML-LINES-004: says when the file is not valid UTF-8, as the place import does', () => { const bytes = Buffer.concat([ Buffer.from(''), Buffer.from([0xff]), Buffer.from('1,1 2,2'), ]); const read = readKmlLines(bytes); expect(read.lines).toHaveLength(1); expect(read.warnings).toEqual(['The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.']); }); it('KML-LINES-005: refuses a document that is not well-formed XML with the reason malformed', () => { let error: unknown; try { readKmlLines(Buffer.from('')); } catch (err) { error = err; } expect(error).toBeInstanceOf(KmlReadError); expect(error).toMatchObject({ reason: 'malformed', message: 'Malformed KML: invalid XML structure' }); }); }); describe('kmlLineGeometry (IO-02): heights only where they are heights', () => { const points = (...eles: Array) => eles.map((ele, i) => ({ lat: 47 + i, lng: 11, ele })); it('KML-HEIGHTS-001: keeps the heights of an absolute line, also when they are 0', () => { expect(kmlLineGeometry(points(0, 0), 'absolute')).toEqual([ [47, 11, 0], [48, 11, 0], ]); }); it('KML-HEIGHTS-002: drops them under any other altitude mode', () => { for (const mode of ['clampToGround', 'relativeToGround', 'clampToSeaFloor']) { expect(kmlLineGeometry(points(400, 420), mode)).toEqual([ [47, 11], [48, 11], ]); } }); it('KML-HEIGHTS-003: without a mode keeps them unless every one is 0, the placeholder Google My Maps writes', () => { expect(kmlLineGeometry(points(0, 0, 0), null)).toEqual([ [47, 11], [48, 11], [49, 11], ]); expect(kmlLineGeometry(points(0, 12), null)).toEqual([ [47, 11, 0], [48, 11, 12], ]); }); it('KML-HEIGHTS-004: a line with a point lacking its height keeps none', () => { expect(kmlLineGeometry(points(400, null), 'absolute')).toEqual([ [47, 11], [48, 11], ]); }); it('KML-HEIGHTS-005: an altitude mode around the lines is inherited by them', () => { const read = readKmlLines( kml(`clampToGround 11,48,300 11.01,48.01,310`), ); expect(read.lines[0]?.geometry).toEqual([ [48, 11], [48.01, 11.01], ]); }); }); describe('KMZ unpacking limits (IO-02)', () => { it('KMZ-CAP-001: counts the bytes it inflates and stops past the limit, whatever the header claims', async () => { const entry = fakeEntry([Buffer.alloc(6, 'a'), Buffer.alloc(6, 'b')]); await expect(readKmzEntryCapped(entry, 10)).rejects.toMatchObject({ reason: 'archive_too_large' }); expect((await readKmzEntryCapped(entry, 12)).toString()).toBe('aaaaaabbbbbb'); }); it('KMZ-CAP-002: names the reason for each archive it cannot read', async () => { await expect(unpackKmzToKml(Buffer.from('this is not a zip'))).rejects.toMatchObject({ reason: 'archive_invalid' }); await expect(unpackKmzToKml(fs.readFileSync(KMZ_FIXTURE), 1)).rejects.toMatchObject({ reason: 'archive_too_large', }); }); it('KMZ-CAP-003: a KML entry that breaks while it inflates is an invalid archive', async () => { const broken = { path: 'doc.kml', uncompressedSize: 10, ...fakeEntry([Buffer.from('')], new Error('bad')) }; const open = vi.spyOn(unzipper.Open, 'buffer').mockResolvedValueOnce({ files: [broken] } as never); await expect(unpackKmzToKml(Buffer.from('zip'))).rejects.toMatchObject({ reason: 'archive_invalid', message: 'Invalid KMZ archive.', }); open.mockRestore(); }); it('KMZ-CAP-004: reads only the KML entry of an archive, never the files beside it', async () => { const image = { path: 'files/photo.jpg', uncompressedSize: 1, stream: vi.fn() }; const doc = { path: 'doc.kml', uncompressedSize: 5, ...fakeEntry([Buffer.from('')]) }; const open = vi.spyOn(unzipper.Open, 'buffer').mockResolvedValueOnce({ files: [image, doc] } as never); expect((await unpackKmzToKml(Buffer.from('zip'))).toString()).toBe(''); expect(image.stream).not.toHaveBeenCalled(); open.mockRestore(); }); it('KMZ-CAP-005: takes chunks that come as plain byte arrays', async () => { const chunks = [new Uint8Array([60, 107]), Buffer.from('ml/>')]; const entry = { stream: () => Readable.from(chunks) as unknown as Entry }; expect((await readKmzEntryCapped(entry, 10)).toString()).toBe(''); }); });