|
| 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