Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion services/export/tdei.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ export class TdeiExporter {
//
await Promise.all(changesets.map(async (cs) => {
const osc = await this._osmClient.downloadOsmChange(workspace.id, cs.id);
const osmChange = parseOsmChangeXml(osc);
const osmChange = await parseOsmChangeXml(osc);

files.set(`changesets/${cs.id}.json`, JSON.stringify(osmChange));

Expand Down
2 changes: 1 addition & 1 deletion services/osm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient {
}

async getOsmChange(workspaceId: WorkspaceId, changesetId: number): Promise<OsmChange> {
const osmChange = parseOsmChangeXml(await this.downloadOsmChange(workspaceId, changesetId));
const osmChange = await parseOsmChangeXml(await this.downloadOsmChange(workspaceId, changesetId));

for (const type of OSMCHANGE_ACTION_TYPES) {
for (const element of osmChange[type] ?? []) {
Expand Down
72 changes: 72 additions & 0 deletions test/unit/services/osm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { OsmApiClient } from '~/services/osm';
import { server } from '../../mocks/server';

import type { TdeiClient } from '~/services/tdei';

// getOsmChange only needs its tdei collaborator for auth: a no-op refresh and an
// `auth` object whose `complete` flag gates the Authorization header. The real
// BaseHttpClient runs end to end — MSW intercepts at fetch, so URL building and
// the async XML parse are exercised.
const tdeiClient = {
tryRefreshAuth: async () => {},
auth: { complete: false, accessToken: '' }
} as unknown as TdeiClient;

const OSM_WEB_BASE = 'http://api.test/osm/';
const OSM_API_BASE = 'http://api.test/osm/api/0.6/';

// A minimal but real osmChange document: one created node carrying a tag and a
// timestamp, so we can assert both the parsed structure and the Date coercion.
const OSM_CHANGE_XML = `<osmChange version="0.6">
<create>
<node id="1" lat="47.6" lon="-122.3" version="1" changeset="42" timestamp="2026-07-01T00:00:00Z" user="tester" uid="9">
<tag k="highway" v="crossing"/>
</node>
</create>
</osmChange>`;

function makeClient() {
return new OsmApiClient(OSM_WEB_BASE, OSM_API_BASE, tdeiClient);
}

function stubDownload(xml: string = OSM_CHANGE_XML) {
server.use(
http.get(`${OSM_API_BASE}changeset/:id/download`, () =>
HttpResponse.text(xml, { headers: { 'Content-Type': 'application/xml' } })
)
);
}

describe('OsmApiClient.getOsmChange', () => {
// Regression: parseOsmChangeXml is async (it resolves a Promise once the
// streaming sax parse completes). Using it without `await` left a Promise
// here, which JSON.stringify's to "{}" — the bug that made the exported
// changesets/{id}.json empty even when the .osc had content.
it('parses the downloaded osmChange XML into a populated object', async () => {
stubDownload();

const osmChange = await makeClient().getOsmChange(1, 777);

expect(JSON.stringify(osmChange)).not.toBe('{}');
expect(osmChange.create).toHaveLength(1);

const element = osmChange.create![0]!;
expect(element.type).toBe('node');
expect(element.tags).toEqual({ highway: 'crossing' });
});

// The same missing `await` also silently skipped getOsmChange's normalization
// loop (it iterated the Promise, not the elements), so timestamps stayed raw
// strings. With the await, each element.timestamp is coerced to a Date.
it('normalizes each element timestamp into a Date', async () => {
stubDownload();

const osmChange = await makeClient().getOsmChange(1, 777);

const element = osmChange.create![0]!;
expect(element.timestamp).toBeInstanceOf(Date);
expect(element.timestamp.toISOString()).toBe('2026-07-01T00:00:00.000Z');
});
});
10 changes: 9 additions & 1 deletion types/osmchange-parser.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
declare module '@osmcha/osmchange-parser';
declare module '@osmcha/osmchange-parser' {
import type { OsmChange } from './osm';

// The parser is async (built on the streaming `sax` parser); it resolves once
// parsing completes. Typing it as a Promise prevents callers from using the
// result without awaiting — doing so yields a Promise that JSON-stringifies to
// "{}" and whose `create`/`modify`/`delete` reads are silently undefined.
export default function parseOsmChangeXml(xmlString: string): Promise<OsmChange>;
}
Loading