Skip to content

Commit ea1d514

Browse files
authored
fix: persist newly added sites to cloud sync
Mark newly created site-library entries dirty so the next delta sync includes them. Adds a regression test for the real post-init delta sync path.
1 parent c88d7c6 commit ea1d514

4 files changed

Lines changed: 186 additions & 4 deletions

File tree

functions/_lib/buildInfo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
export const APP_VERSION = "0.15.0";
2-
export const APP_COMMIT = "c81acafb";
1+
export const APP_VERSION = "0.16.0";
2+
export const APP_COMMIT = "c88d7c63";
33
export const APP_BUILD_LABEL = `v${APP_VERSION}+${APP_COMMIT}`;
44
export type BuildChannel = "stable" | "beta" | "alpha";
55
export const buildLabelForChannel = (channel: BuildChannel): string => {

src/lib/buildInfo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
export const APP_VERSION = "0.15.0";
2-
export const APP_COMMIT = "c81acafb";
1+
export const APP_VERSION = "0.16.0";
2+
export const APP_COMMIT = "c88d7c63";
33
export const APP_BUILD_LABEL = `v${APP_VERSION}+${APP_COMMIT}`;
44
export type BuildChannel = "stable" | "beta" | "alpha";
55
export const buildLabelForChannel = (channel: BuildChannel): string => {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { CloudUser } from "../lib/cloudUser";
3+
4+
const storage = vi.hoisted(() => {
5+
const data = new Map<string, string>();
6+
const mock = {
7+
getItem: (key: string) => data.get(key) ?? null,
8+
setItem: (key: string, value: string) => {
9+
data.set(key, String(value));
10+
},
11+
removeItem: (key: string) => {
12+
data.delete(key);
13+
},
14+
clear: () => {
15+
data.clear();
16+
},
17+
key: (index: number) => Array.from(data.keys())[index] ?? null,
18+
get length() {
19+
return data.size;
20+
},
21+
};
22+
vi.stubGlobal("localStorage", mock);
23+
vi.stubGlobal("window", {
24+
localStorage: mock,
25+
setTimeout,
26+
clearTimeout,
27+
});
28+
return { mock };
29+
});
30+
31+
vi.mock("../lib/coverage", () => ({
32+
buildCoverage: vi.fn(() => []),
33+
}));
34+
35+
vi.mock("../lib/elevationService", () => ({
36+
fetchElevations: vi.fn(async () => [123]),
37+
}));
38+
39+
const mkUser = (): CloudUser => ({
40+
id: "owner-1",
41+
username: "owner",
42+
avatarUrl: "",
43+
role: "user",
44+
accountState: "approved",
45+
isApproved: true,
46+
isAdmin: false,
47+
isModerator: false,
48+
createdAt: "",
49+
updatedAt: null,
50+
approvedAt: null,
51+
approvedByUserId: null,
52+
email: undefined,
53+
emailPublic: true,
54+
bio: "",
55+
});
56+
57+
const baselinePayload: any = {
58+
siteLibrary: [],
59+
simulationPresets: [
60+
{
61+
id: "sim-1",
62+
name: "Simulation One",
63+
slug: "simulation-one",
64+
slugAliases: [],
65+
visibility: "shared",
66+
sharedWith: [],
67+
ownerUserId: "owner-1",
68+
createdByUserId: "owner-1",
69+
createdByName: "owner",
70+
createdByAvatarUrl: "",
71+
lastEditedByUserId: "owner-1",
72+
lastEditedByName: "owner",
73+
lastEditedByAvatarUrl: "",
74+
updatedAt: "2026-01-01T00:00:00.000Z",
75+
snapshot: {
76+
sites: [],
77+
links: [],
78+
systems: [],
79+
networks: [],
80+
selectedSiteId: "",
81+
selectedLinkId: "",
82+
selectedNetworkId: "",
83+
selectedCoverageResolution: "24",
84+
propagationModel: "ITM",
85+
selectedFrequencyPresetId: "custom",
86+
rxSensitivityTargetDbm: -120,
87+
environmentLossDb: 0,
88+
propagationEnvironment: {
89+
radioClimate: "Continental Temperate",
90+
polarization: "Vertical",
91+
clutterHeightM: 3,
92+
groundDielectric: 15,
93+
groundConductivity: 0.005,
94+
atmosphericBendingNUnits: 301,
95+
},
96+
autoPropagationEnvironment: true,
97+
terrainDataset: "copernicus30",
98+
},
99+
effectiveRole: "owner",
100+
},
101+
],
102+
};
103+
104+
const makeResponse = (body: unknown) =>
105+
({
106+
ok: true,
107+
status: 200,
108+
statusText: "OK",
109+
json: async () => body,
110+
}) as Response;
111+
112+
const cloneJson = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
113+
114+
describe("appStore delta sync", () => {
115+
beforeEach(() => {
116+
storage.mock.clear();
117+
vi.restoreAllMocks();
118+
vi.useFakeTimers();
119+
window.setTimeout = setTimeout;
120+
window.clearTimeout = clearTimeout;
121+
});
122+
123+
afterEach(() => {
124+
vi.useRealTimers();
125+
});
126+
127+
it("includes a newly added site in the next delta sync payload", async () => {
128+
const fetchBodies: string[] = [];
129+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
130+
const url = typeof input === "string" ? input : input.toString();
131+
const method = (init?.method ?? "GET").toUpperCase();
132+
if (url.includes("/api/library") && method === "GET") {
133+
return makeResponse(cloneJson(baselinePayload));
134+
}
135+
if (url.includes("/api/library") && method === "PUT") {
136+
fetchBodies.push(String(init?.body ?? ""));
137+
return makeResponse({ ok: true, conflicts: [] });
138+
}
139+
throw new Error(`Unexpected fetch: ${method} ${url}`);
140+
});
141+
vi.stubGlobal("fetch", fetchMock);
142+
143+
const { useAppStore } = await import("./appStore");
144+
useAppStore.setState({
145+
currentUser: mkUser(),
146+
authState: "signed_in",
147+
selectedScenarioId: "sim-1",
148+
selectedSiteId: "",
149+
selectedSiteIds: [],
150+
selectedLinkId: "",
151+
selectedNetworkId: "",
152+
sites: [],
153+
links: [],
154+
systems: [],
155+
networks: [],
156+
siteLibrary: [],
157+
simulationPresets: cloneJson(baselinePayload.simulationPresets),
158+
syncStatus: "synced",
159+
syncPending: false,
160+
syncBusy: false,
161+
isInitializing: false,
162+
isOnline: true,
163+
});
164+
165+
await useAppStore.getState().initializeCloudSync();
166+
167+
useAppStore.getState().addSiteByCoordinates("Gamma", 3, 3);
168+
const addedSiteId = useAppStore.getState().siteLibrary[0]?.id;
169+
expect(addedSiteId).toMatch(/^libsite-/);
170+
171+
useAppStore.getState().performCloudSyncPush();
172+
await vi.advanceTimersByTimeAsync(2500);
173+
await Promise.resolve();
174+
175+
expect(fetchBodies).toHaveLength(1);
176+
const payload = JSON.parse(fetchBodies[0]) as { siteLibrary: Array<{ id: string; name: string }>; simulationPresets: unknown[] };
177+
expect(payload.siteLibrary).toHaveLength(1);
178+
expect(payload.siteLibrary[0]?.id).toBe(addedSiteId);
179+
expect(payload.siteLibrary[0]?.name).toBe("Gamma");
180+
});
181+
});

src/store/appStore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2145,6 +2145,7 @@ export const useAppStore = create<AppState>((set, get) => ({
21452145
lastEditedByAvatarUrl: currentUser.avatarUrl ?? "",
21462146
effectiveRole: "owner" as const,
21472147
};
2148+
markDirtySite(entry.id);
21482149
const nextLibrary = normalizeSiteLibrary([entry, ...state.siteLibrary]);
21492150
writeStorage(SITE_LIBRARY_KEY, nextLibrary);
21502151
return {

0 commit comments

Comments
 (0)