Skip to content

Commit 1af9307

Browse files
authored
Merge pull request #22614 from mvdbeek/sse_followup_1
SSE update enhancements for multi-history view and published histories
2 parents 7572964 + 289ab6d commit 1af9307

18 files changed

Lines changed: 1038 additions & 49 deletions

File tree

client/src/api/schema/schema.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,31 @@ export interface paths {
12921292
patch?: never;
12931293
trace?: never;
12941294
};
1295+
"/api/events/history-subscriptions": {
1296+
parameters: {
1297+
query?: never;
1298+
header?: never;
1299+
path?: never;
1300+
cookie?: never;
1301+
};
1302+
get?: never;
1303+
put?: never;
1304+
/**
1305+
* Subscribe to history_update SSE events for histories you don't own.
1306+
* @description Asks every webapp worker to start routing ``history_update`` events
1307+
* for these histories to the requesting user/session, in addition to the
1308+
* default owner-routing. Idempotent: re-subscribing to the same id is a
1309+
* no-op. Clients re-send the full set after each ``EventSource.onopen``
1310+
* so reconnects don't drop subscriptions.
1311+
*/
1312+
post: operations["subscribe_history_viewer_api_events_history_subscriptions_post"];
1313+
/** Cancel viewer subscriptions for these histories. */
1314+
delete: operations["unsubscribe_history_viewer_api_events_history_subscriptions_delete"];
1315+
options?: never;
1316+
head?: never;
1317+
patch?: never;
1318+
trace?: never;
1319+
};
12951320
"/api/events/stream": {
12961321
parameters: {
12971322
query?: never;
@@ -15433,6 +15458,14 @@ export interface components {
1543315458
*/
1543415459
url: string;
1543515460
};
15461+
/**
15462+
* HistoryViewerSubscriptionPayload
15463+
* @description REST payload for ``/api/events/history-subscriptions`` endpoints.
15464+
*/
15465+
HistoryViewerSubscriptionPayload: {
15466+
/** History Ids */
15467+
history_ids: string[];
15468+
};
1543615469
/**
1543715470
* Hyperlink
1543815471
* @description Represents some text with an Hyperlink.
@@ -33478,6 +33511,92 @@ export interface operations {
3347833511
};
3347933512
};
3348033513
};
33514+
subscribe_history_viewer_api_events_history_subscriptions_post: {
33515+
parameters: {
33516+
query?: never;
33517+
header?: {
33518+
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
33519+
"run-as"?: string | null;
33520+
};
33521+
path?: never;
33522+
cookie?: never;
33523+
};
33524+
requestBody: {
33525+
content: {
33526+
"application/json": components["schemas"]["HistoryViewerSubscriptionPayload"];
33527+
};
33528+
};
33529+
responses: {
33530+
/** @description Successful Response */
33531+
204: {
33532+
headers: {
33533+
[name: string]: unknown;
33534+
};
33535+
content?: never;
33536+
};
33537+
/** @description Request Error */
33538+
"4XX": {
33539+
headers: {
33540+
[name: string]: unknown;
33541+
};
33542+
content: {
33543+
"application/json": components["schemas"]["MessageExceptionModel"];
33544+
};
33545+
};
33546+
/** @description Server Error */
33547+
"5XX": {
33548+
headers: {
33549+
[name: string]: unknown;
33550+
};
33551+
content: {
33552+
"application/json": components["schemas"]["MessageExceptionModel"];
33553+
};
33554+
};
33555+
};
33556+
};
33557+
unsubscribe_history_viewer_api_events_history_subscriptions_delete: {
33558+
parameters: {
33559+
query?: never;
33560+
header?: {
33561+
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
33562+
"run-as"?: string | null;
33563+
};
33564+
path?: never;
33565+
cookie?: never;
33566+
};
33567+
requestBody: {
33568+
content: {
33569+
"application/json": components["schemas"]["HistoryViewerSubscriptionPayload"];
33570+
};
33571+
};
33572+
responses: {
33573+
/** @description Successful Response */
33574+
204: {
33575+
headers: {
33576+
[name: string]: unknown;
33577+
};
33578+
content?: never;
33579+
};
33580+
/** @description Request Error */
33581+
"4XX": {
33582+
headers: {
33583+
[name: string]: unknown;
33584+
};
33585+
content: {
33586+
"application/json": components["schemas"]["MessageExceptionModel"];
33587+
};
33588+
};
33589+
/** @description Server Error */
33590+
"5XX": {
33591+
headers: {
33592+
[name: string]: unknown;
33593+
};
33594+
content: {
33595+
"application/json": components["schemas"]["MessageExceptionModel"];
33596+
};
33597+
};
33598+
};
33599+
};
3348133600
stream_events_api_events_stream_get: {
3348233601
parameters: {
3348333602
query?: never;
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { getLocalVue } from "@tests/vitest/helpers";
2+
import { shallowMount } from "@vue/test-utils";
3+
import flushPromises from "flush-promises";
4+
import { createPinia, setActivePinia } from "pinia";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
import type { RegisteredUser } from "@/api";
8+
import { useServerMock } from "@/api/client/__mocks__";
9+
import { setSseConnected, setSseHasEverConnected, sseMockFactory } from "@/stores/_testing/sseStoreSupport";
10+
import { useConfigStore } from "@/stores/configurationStore";
11+
import { useUserStore } from "@/stores/userStore";
12+
13+
import HistoryCounter from "./HistoryCounter.vue";
14+
15+
const sseState = vi.hoisted(() => ({
16+
onEvent: null as ((event: MessageEvent) => void) | null,
17+
connect: vi.fn(),
18+
disconnect: vi.fn(),
19+
}));
20+
21+
vi.mock("@/composables/useNotificationSSE", () => sseMockFactory(sseState));
22+
23+
// userStore wires its localStorage-backed refs through this composable; the
24+
// real watcher hits ``window.localStorage`` which jsdom doesn't expose with a
25+
// usable Storage prototype here. We don't read any of those refs in this
26+
// test, so a ref-returning stub is enough to keep userStore initialization
27+
// happy.
28+
vi.mock("@/composables/userLocalStorageFromHashedId", async () => {
29+
const { ref } = await import("vue");
30+
return {
31+
useUserLocalStorageFromHashId: <T>(_key: string, initialValue: T) => ref(initialValue),
32+
};
33+
});
34+
35+
const { server, http } = useServerMock();
36+
37+
const localVue = getLocalVue();
38+
39+
const baseHistory = {
40+
id: "hist-1",
41+
name: "Test history",
42+
user_id: "user-1",
43+
size: 0,
44+
contents_active: { active: 0, deleted: 0, hidden: 0 },
45+
update_time: new Date().toISOString(),
46+
create_time: new Date().toISOString(),
47+
deleted: false,
48+
archived: false,
49+
purged: false,
50+
published: false,
51+
};
52+
53+
function registerConfigHandler(enableSse: boolean): void {
54+
server.use(
55+
http.get("/api/configuration", ({ response }) => {
56+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
57+
return response(200).json({ enable_sse_updates: enableSse } as any);
58+
}),
59+
);
60+
}
61+
62+
function setEnableSse(enabled: boolean): void {
63+
registerConfigHandler(enabled);
64+
// The store kicks off ``loadConfig`` on creation; ``setConfiguration``
65+
// makes the value visible synchronously regardless of the network round
66+
// trip so the component reads it on mount.
67+
useConfigStore().setConfiguration({ enable_sse_updates: enabled } as never);
68+
// The refresh button is gated on ``currentUser``; without a logged-in
69+
// user the BButtonGroup that contains it is never rendered.
70+
useUserStore().currentUser = { id: "user-1", email: "u@example.com" } as RegisteredUser;
71+
}
72+
73+
function mountCounter(props: Partial<{ lastChecked: Date; isWatching: boolean }> = {}) {
74+
return shallowMount(HistoryCounter as unknown as object, {
75+
propsData: {
76+
history: baseHistory,
77+
lastChecked: props.lastChecked ?? new Date(),
78+
isWatching: props.isWatching ?? true,
79+
},
80+
localVue,
81+
});
82+
}
83+
84+
function refreshButton(wrapper: ReturnType<typeof shallowMount>) {
85+
return wrapper.get(".history-refresh-button");
86+
}
87+
88+
describe("HistoryCounter — refresh button", () => {
89+
beforeEach(() => {
90+
setActivePinia(createPinia());
91+
sseState.connect.mockClear();
92+
sseState.disconnect.mockClear();
93+
// sseMockFactory lazily creates these refs on first call; reset to a
94+
// known state for each test.
95+
Reflect.deleteProperty(sseState, "connected");
96+
Reflect.deleteProperty(sseState, "hasEverConnected");
97+
// Re-create refs by invoking the factory once — every component mount
98+
// already triggers this, but doing it explicitly makes the per-test
99+
// state setup obvious.
100+
sseMockFactory(sseState);
101+
vi.useFakeTimers();
102+
});
103+
104+
afterEach(() => {
105+
vi.useRealTimers();
106+
});
107+
108+
describe("SSE mode", () => {
109+
beforeEach(() => {
110+
setEnableSse(true);
111+
});
112+
113+
it('shows "Refresh history" with a link variant when the connection is healthy', async () => {
114+
setSseConnected(sseState, true);
115+
setSseHasEverConnected(sseState, true);
116+
117+
const wrapper = mountCounter();
118+
await flushPromises();
119+
120+
const button = refreshButton(wrapper);
121+
expect(button.attributes("title")).toBe("Refresh history");
122+
expect(button.attributes("variant")).toBe("link");
123+
});
124+
125+
it("does not flag the initial-connect window as a connection loss", async () => {
126+
// EventSource hasn't opened yet — connected=false, hasEverConnected=false.
127+
setSseConnected(sseState, false);
128+
setSseHasEverConnected(sseState, false);
129+
130+
const wrapper = mountCounter();
131+
await flushPromises();
132+
133+
const button = refreshButton(wrapper);
134+
expect(button.attributes("title")).toBe("Refresh history");
135+
expect(button.attributes("variant")).toBe("link");
136+
});
137+
138+
it("turns red when the SSE connection is lost after a successful open", async () => {
139+
setSseConnected(sseState, true);
140+
setSseHasEverConnected(sseState, true);
141+
142+
const wrapper = mountCounter();
143+
await flushPromises();
144+
145+
// Simulate the EventSource onerror path: connection drops after
146+
// it had previously been established.
147+
setSseConnected(sseState, false);
148+
await flushPromises();
149+
150+
const button = refreshButton(wrapper);
151+
expect(button.attributes("title")).toBe("Live updates disconnected. Click to refresh.");
152+
expect(button.attributes("variant")).toBe("danger");
153+
});
154+
});
155+
156+
describe("polling mode", () => {
157+
beforeEach(() => {
158+
setEnableSse(false);
159+
});
160+
161+
it("shows the legacy 'Last refreshed …' title with a link variant when fresh", async () => {
162+
const wrapper = mountCounter({ lastChecked: new Date(), isWatching: true });
163+
await flushPromises();
164+
165+
const button = refreshButton(wrapper);
166+
expect(button.attributes("title")).toMatch(/^Last refreshed .+ ago$/);
167+
expect(button.attributes("variant")).toBe("link");
168+
});
169+
170+
it("turns red after 2 minutes of staleness", async () => {
171+
// 3 minutes ago — past the 120000ms cutoff in HistoryCounter.
172+
const stale = new Date(Date.now() - 3 * 60 * 1000);
173+
const wrapper = mountCounter({ lastChecked: stale, isWatching: true });
174+
await flushPromises();
175+
176+
const button = refreshButton(wrapper);
177+
expect(button.attributes("title")).toMatch(/Consider reloading the page\.$/);
178+
expect(button.attributes("variant")).toBe("danger");
179+
});
180+
181+
it("turns red when the resource watcher reports it is no longer watching", async () => {
182+
const wrapper = mountCounter({ lastChecked: new Date(), isWatching: false });
183+
await flushPromises();
184+
185+
const button = refreshButton(wrapper);
186+
expect(button.attributes("variant")).toBe("danger");
187+
});
188+
});
189+
190+
it("emits reloadContents when the refresh button is clicked", async () => {
191+
setEnableSse(true);
192+
setSseConnected(sseState, true);
193+
setSseHasEverConnected(sseState, true);
194+
195+
const wrapper = mountCounter();
196+
await flushPromises();
197+
await refreshButton(wrapper).trigger("click");
198+
199+
expect(wrapper.emitted("reloadContents")).toBeTruthy();
200+
expect(wrapper.emitted("reloadContents")?.length).toBe(1);
201+
});
202+
});

0 commit comments

Comments
 (0)