Skip to content

Commit 4c314a2

Browse files
committed
setup a mock DB system, to enable unit tests for changeset uploading
1 parent e92ac62 commit 4c314a2

File tree

2 files changed

+232
-29
lines changed

2 files changed

+232
-29
lines changed

src/api/_rawResponse.d.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,21 @@ export type RawOsmChange = {
6969
},
7070
];
7171
};
72+
73+
/** @internal */
74+
export interface RawIdMap {
75+
old_id: `${number}`;
76+
new_id: `${number}`;
77+
new_version: `${number}`;
78+
}
79+
80+
/** @internal */
81+
export type RawUploadResponse = {
82+
diffResult: [
83+
{
84+
[T in OsmFeatureType]?: { $: RawIdMap }[];
85+
} & {
86+
$: { generator: string; version: string };
87+
},
88+
];
89+
};

src/api/changesets/__tests__/uploadChangeset.test.ts

Lines changed: 214 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,234 @@
1+
/* eslint-disable unicorn/prevent-abbreviations -- db is not ambiguous */
12
import { beforeEach, describe, expect, it, vi } from "vitest";
23
import type { OsmChange, OsmFeature, OsmFeatureType } from "../../../types";
34
import { uploadChangeset } from "../uploadChangeset";
45
import { chunkOsmChange } from "../chunkOsmChange";
56
import { osmFetch } from "../../_osmFetch";
67
import { version } from "../../../../package.json";
8+
import { parseOsmChangeXml } from "../_parseOsmChangeXml";
9+
import type { RawUploadResponse } from "../../_rawResponse";
710

8-
let nextId = 0;
9-
vi.mock("../../_osmFetch", () => ({ osmFetch: vi.fn(() => ++nextId) }));
10-
11-
/** use with {@link Array.sort} to randomise the order */
12-
const shuffle = () => 0.5 - Math.random();
13-
14-
const createMockFeatures = (
15-
type: OsmFeatureType,
16-
count: number,
17-
_label: string
18-
) =>
19-
Array.from<OsmFeature>({ length: count }).fill(<never>{
20-
type,
21-
_label,
22-
nodes: [],
23-
members: [],
11+
vi.mock("../../_osmFetch");
12+
13+
/**
14+
* mocks the OSM API, should behave the same
15+
* for uploading/downloading features.
16+
*/
17+
class MockDatabase {
18+
#nextId: Partial<Record<OsmFeatureType | "changeset", number>> = {};
19+
20+
#db: OsmFeature[];
21+
22+
getNextId(type: OsmFeatureType | "changeset") {
23+
this.#nextId[type] ||= 0;
24+
return ++this.#nextId[type];
25+
}
26+
27+
constructor(db: OsmFeature[] = []) {
28+
this.#db = structuredClone(db);
29+
}
30+
31+
getFeature(type: OsmFeatureType, ids: number[]) {
32+
return {
33+
elements: this.#db.filter((x) => x.type === type && ids.includes(x.id)),
34+
};
35+
}
36+
37+
onUpload = vi.fn((_osmChange: OsmChange) => {
38+
const osmChange = structuredClone(_osmChange);
39+
const response: RawUploadResponse = {
40+
diffResult: [{ $: { generator: "", version: "" } }],
41+
};
42+
43+
// create is easy. just need to allocate a new ID
44+
this.#db.push(
45+
...osmChange.create.map((feature) => {
46+
const oldId = feature.id;
47+
const newId = this.getNextId(feature.type);
48+
49+
feature.version = 1;
50+
51+
response.diffResult[0][feature.type] ||= [];
52+
response.diffResult[0][feature.type]!.push({
53+
$: {
54+
old_id: `${oldId}`,
55+
new_id: `${newId}`,
56+
new_version: `${feature.version}`,
57+
},
58+
});
59+
return { ...feature, id: newId };
60+
})
61+
);
62+
63+
// modify & delete needs to check for conflicts
64+
for (const type of <const>["modify", "delete"]) {
65+
for (const local of osmChange[type]) {
66+
const remoteIndex = this.#db.findIndex(
67+
(x) => x.type === local.type && x.id === local.id
68+
);
69+
const remote = this.#db[remoteIndex];
70+
71+
const diffId = `${local.type[0]}${local.id}@(${local.version}${remote?.version || ""})`;
72+
73+
if (!remote) throw new Error(`404 ${local.type}/${local.id}`);
74+
if (remote.version !== local.version) {
75+
throw Object.assign(new Error(`409 ${diffId}`), { cause: 409 });
76+
}
77+
78+
local.version++;
79+
80+
response.diffResult[0][local.type] ||= [];
81+
response.diffResult[0][local.type]!.push({
82+
$: {
83+
old_id: `${local.id}`,
84+
new_id: `${local.id}`,
85+
new_version: `${local.version}`,
86+
},
87+
});
88+
89+
if (type === "delete") {
90+
this.#db.splice(remoteIndex, 1);
91+
} else {
92+
this.#db[remoteIndex] = local;
93+
}
94+
}
95+
}
96+
97+
return response;
2498
});
2599

100+
onRequest: typeof osmFetch = async <T>(
101+
url: string,
102+
_qs: unknown,
103+
options?: RequestInit
104+
): Promise<T> => {
105+
if (url.endsWith("/create")) return <T>this.getNextId("changeset");
106+
107+
if (url.endsWith("/close")) return <T>undefined;
108+
109+
if (url.endsWith("/upload")) {
110+
let xml;
111+
if ("Content-Encoding" in options!.headers!) {
112+
const stream = new Response(options?.body).body!.pipeThrough(
113+
new DecompressionStream("gzip")
114+
);
115+
xml = await new Response(stream).text();
116+
} else {
117+
xml = <string>options!.body;
118+
}
119+
const json = parseOsmChangeXml(xml);
120+
return this.onUpload(json) as T;
121+
}
122+
123+
const getMatch = url.match(/(node|way|relation)s\.json/)?.[1];
124+
if (getMatch) {
125+
return <T>(
126+
this.getFeature(
127+
<OsmFeatureType>getMatch,
128+
new URLSearchParams(url.split("?")[1])
129+
.get(`${getMatch}s`)!
130+
.split(",")
131+
.map(Number)
132+
)
133+
);
134+
}
135+
136+
// update changeset tags
137+
if (/\/0.6\/changeset\/(\d+)/.test(url)) return <T>undefined;
138+
139+
throw new Error(`invalid request ${url}`);
140+
};
141+
}
142+
143+
let db: MockDatabase;
144+
145+
/** useless props */
146+
const JUNK: Omit<OsmFeature, "id" | "type" | "version"> = {
147+
changeset: -1,
148+
timestamp: "",
149+
uid: -1,
150+
user: "",
151+
};
152+
153+
const MOCK_FEATURES: OsmFeature[] = [
154+
{ ...JUNK, type: "node", id: 1, version: 1, lat: 0, lon: 0 },
155+
{ ...JUNK, type: "node", id: 2, version: 2, lat: 0, lon: 0 },
156+
{ ...JUNK, type: "way", id: 1, version: 2, nodes: [1, 2] },
157+
{ ...JUNK, type: "way", id: 2, version: 1, nodes: [2, 1] },
158+
{
159+
...JUNK,
160+
type: "relation",
161+
id: 1,
162+
version: 10,
163+
members: [{ ref: 1, type: "node", role: "" }],
164+
},
165+
{
166+
...JUNK,
167+
type: "relation",
168+
id: 2,
169+
version: 21,
170+
members: [{ ref: 1, type: "way", role: "outer" }],
171+
},
172+
{
173+
...JUNK,
174+
type: "relation",
175+
id: 3,
176+
version: 1,
177+
members: [{ ref: 1, type: "way", role: "outer" }],
178+
},
179+
{
180+
...JUNK,
181+
type: "relation",
182+
id: 4,
183+
version: 1,
184+
members: [{ ref: 1, type: "way", role: "outer" }],
185+
},
186+
187+
// these are already deleted in the database:
188+
{ ...JUNK, type: "node", id: 3, version: 2, lat: 0, lon: 0, visible: false },
189+
];
190+
26191
describe("uploadChangeset", () => {
27192
beforeEach(() => {
28-
nextId = 0;
193+
db = new MockDatabase(MOCK_FEATURES);
194+
vi.mocked(osmFetch).mockImplementation(db.onRequest);
29195
vi.clearAllMocks();
30196
chunkOsmChange.DEFAULT_LIMIT = 6; // don't do this in production
31197
});
32198

33199
const huge: OsmChange = {
34200
create: [
35-
...createMockFeatures("node", 4, "create"),
36-
...createMockFeatures("way", 3, "create"),
37-
...createMockFeatures("relation", 4, "create"),
38-
].sort(shuffle),
201+
{
202+
...JUNK,
203+
type: "relation",
204+
id: -300000,
205+
version: 1,
206+
members: [{ ref: -3, type: "way", role: "inner" }],
207+
},
208+
{ ...JUNK, type: "node", id: -100, version: 1, lat: 0, lon: 0 },
209+
{ ...JUNK, type: "way", id: -3, version: 1, nodes: [-100, -2] },
210+
{ ...JUNK, type: "node", id: -4, version: 1, lat: 0, lon: 0 },
211+
{ ...JUNK, type: "node", id: -5, version: 1, lat: 0, lon: 0 },
212+
{ ...JUNK, type: "node", id: -6, version: 1, lat: 0, lon: 0 },
213+
{ ...JUNK, type: "node", id: -7, version: 1, lat: 0, lon: 0 },
214+
{ ...JUNK, type: "node", id: -2, version: 1, lat: 0, lon: 0 },
215+
{ ...JUNK, type: "node", id: -8, version: 1, lat: 0, lon: 0 },
216+
{ ...JUNK, type: "node", id: -9, version: 1, lat: 0, lon: 0 },
217+
{ ...JUNK, type: "node", id: -10, version: 1, lat: 0, lon: 0 },
218+
{ ...JUNK, type: "node", id: -11, version: 1, lat: 0, lon: 0 },
219+
],
39220
modify: [
40-
...createMockFeatures("node", 1, "modify"),
41-
...createMockFeatures("way", 1, "modify"),
42-
...createMockFeatures("relation", 1, "modify"),
43-
].sort(shuffle),
221+
{ ...JUNK, type: "node", id: 1, version: 1, lat: 0, lon: 0 },
222+
{ ...JUNK, type: "relation", id: 1, version: 10, members: [] },
223+
{ ...JUNK, type: "way", id: 1, version: 2, nodes: [600, 601] },
224+
],
44225
delete: [
45-
...createMockFeatures("node", 1, "delete"),
46-
...createMockFeatures("way", 2, "delete"),
47-
...createMockFeatures("relation", 3, "delete"),
48-
].sort(shuffle),
226+
{ ...JUNK, type: "relation", id: 2, version: 21, members: [] },
227+
{ ...JUNK, type: "node", id: 2, version: 2, lat: 0, lon: 0 },
228+
{ ...JUNK, type: "relation", id: 3, version: 1, members: [] },
229+
{ ...JUNK, type: "way", id: 2, version: 1, nodes: [600, 601] },
230+
{ ...JUNK, type: "relation", id: 4, version: 1, members: [] },
231+
],
49232
};
50233

51234
it("adds a fallback created_by tag", async () => {
@@ -130,4 +313,6 @@ describe("uploadChangeset", () => {
130313

131314
expect(output).toBe(1);
132315
});
316+
317+
// end of tests
133318
});

0 commit comments

Comments
 (0)