From 4c2225290cd9be2711afb53cd6c837247bc44274 Mon Sep 17 00:00:00 2001 From: momen Date: Tue, 19 May 2026 00:44:36 +0800 Subject: [PATCH] fix(account): record downloads after success --- src/App.vue | 44 +++- .../unit/App.download-records.test.ts | 191 ++++++++++++++++++ src/__tests__/unit/AppDetailPage.test.ts | 85 +++++++- src/__tests__/unit/ReviewsPanel.test.ts | 117 ++++++++++- src/components/AppDetailPage.vue | 22 +- src/components/ReviewsPanel.vue | 26 ++- 6 files changed, 465 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/unit/App.download-records.test.ts diff --git a/src/App.vue b/src/App.vue index 9e64dfcf..1bb3eb49 100644 --- a/src/App.vue +++ b/src/App.vue @@ -314,6 +314,7 @@ import type { App, AppJson, DownloadItem, + DownloadResult, ChannelPayload, CategoryInfo, HomeLink, @@ -327,6 +328,7 @@ import type { InstalledAppInfo, ResolvedFavoriteItem, SystemInfo, + DownloadedAppRecord, } from "./global/typedefinition"; import type { Ref } from "vue"; import type { IpcRendererEvent } from "electron"; @@ -407,6 +409,8 @@ const favoriteLoading = ref(false); const favoriteError = ref(""); const favoriteRequestGeneration = ref(0); const systemInfo = ref({ distro: "unknown" }); +type PendingDownloadRecord = Omit; +const pendingDownloadRecords = new Map(); /** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */ const storeFilter = ref<"spark" | "apm" | "both">("both"); @@ -1243,16 +1247,29 @@ const onDetailInstall = async (app: App) => { const download = await handleInstall(app); if (!download || !isLoggedIn.value) return; + pendingDownloadRecords.set(download.id, { + appKey: buildFavoriteAppKey(app), + pkgname: app.pkgname, + name: app.name, + category: app.category, + selectedOrigin: app.origin, + version: app.version, + packageArch: app.arch || parsePackageArch(app.filename), + }); +}; + +const handleInstallCompleteForDownloadRecord = async ( + _event: IpcRendererEvent, + result: DownloadResult, +) => { + const pendingRecord = pendingDownloadRecords.get(result.id); + if (!pendingRecord) return; + + pendingDownloadRecords.delete(result.id); + if (!result.success || !isLoggedIn.value) return; + try { - await recordDownloadedApp({ - appKey: buildFavoriteAppKey(app), - pkgname: app.pkgname, - name: app.name, - category: app.category, - selectedOrigin: app.origin, - version: app.version, - packageArch: app.arch || parsePackageArch(app.filename), - }); + await recordDownloadedApp(pendingRecord); } catch (error: unknown) { logger.warn({ err: error }, "记录下载应用失败"); } @@ -1952,6 +1969,11 @@ onMounted(async () => { }, ); + window.ipcRenderer.on( + "install-complete", + handleInstallCompleteForDownloadRecord, + ); + window.ipcRenderer.on( "remove-complete", (_event: IpcRendererEvent, payload: ChannelPayload) => { @@ -1968,6 +1990,10 @@ onMounted(async () => { onUnmounted(() => { updateCenterStore.unbind(); + window.ipcRenderer.off( + "install-complete", + handleInstallCompleteForDownloadRecord, + ); }); // 观察器 diff --git a/src/__tests__/unit/App.download-records.test.ts b/src/__tests__/unit/App.download-records.test.ts new file mode 100644 index 00000000..7611fe63 --- /dev/null +++ b/src/__tests__/unit/App.download-records.test.ts @@ -0,0 +1,191 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import App from "@/App.vue"; +import { recordDownloadedApp } from "@/modules/backendApi"; +import { setAuthSession } from "@/global/authState"; +import type { DownloadResult } from "@/global/typedefinition"; + +const invoke = vi.fn(); +const send = vi.fn(); +const ipcHandlers = new Map void>(); + +vi.mock("axios", () => { + const get = vi.fn(async (url: string) => { + if (url.includes("categories.json")) { + return { data: { office: { zh: "办公" } } }; + } + if (url.includes("/office/applist.json")) { + return { + data: [ + { + Name: "WPS", + Pkgname: "wps", + Version: "1.0.0", + Filename: "wps_1.0.0_amd64.deb", + Torrent_address: "", + Author: "", + Contributor: "", + Website: "", + Update: "", + Size: "", + More: "Office suite", + Tags: "", + img_urls: "[]", + icons: "", + }, + ], + }; + } + return { data: [] }; + }); + const post = vi.fn(async () => ({ data: { ok: true } })); + + return { + default: { + create: () => ({ get, post }), + }, + }; +}); + +vi.mock("@/modules/updateCenter", () => ({ + createUpdateCenterStore: () => ({ + isOpen: { value: false }, + showCloseConfirm: { value: false }, + showMigrationConfirm: { value: false }, + searchQuery: { value: "" }, + selectedTaskKeys: { value: new Set() }, + snapshot: { + value: { items: [], tasks: [], warnings: [], hasRunningTasks: false }, + }, + filteredItems: { value: [] }, + allSelected: { value: false }, + someSelected: { value: false }, + bind: vi.fn(), + unbind: vi.fn(), + open: vi.fn(), + refresh: vi.fn(), + ignoreItem: vi.fn(), + unignoreItem: vi.fn(), + toggleSelection: vi.fn(), + toggleSelectAll: vi.fn(), + getSelectedItems: vi.fn(() => []), + closeNow: vi.fn(), + startSelected: vi.fn(), + requestClose: vi.fn(), + }), +})); + +vi.mock("@/modules/backendApi", () => ({ + addFavoriteItem: vi.fn(), + bulkDeleteFavoriteItems: vi.fn(), + createFavoriteFolder: vi.fn(), + exchangeFlarumToken: vi.fn(), + listFavoriteFolders: vi.fn(async () => []), + listFavoriteItems: vi.fn(async () => []), + recordDownloadedApp: vi.fn(async () => undefined), + setBackendToken: vi.fn(), +})); + +describe("App download records", () => { + beforeEach(() => { + vi.clearAllMocks(); + ipcHandlers.clear(); + invoke.mockImplementation(async (channel: string) => { + if (channel === "get-store-filter") return "apm"; + if (channel === "check-spark-available") return false; + if (channel === "check-apm-available") return true; + if (channel === "get-app-version") return "5.0.0"; + if (channel === "get-system-info") return { distro: "deepin 25" }; + if (channel === "list-installed") return { success: true, apps: [] }; + if (channel === "check-installed") return false; + return []; + }); + + Object.assign(window.ipcRenderer, { + invoke, + send, + on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => { + ipcHandlers.set(channel, handler); + }), + off: vi.fn(), + }); + window.apm_store.arch = "amd64"; + localStorage.clear(); + setAuthSession({ + accessToken: "backend-token", + tokenType: "bearer", + user: { + id: 1, + flarumUserId: "42", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], + }, + }); + + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + vi.stubGlobal("scrollTo", vi.fn()); + class MockIntersectionObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + } + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + }); + + it("records a download only after the queued install completes successfully", async () => { + render(App); + + await fireEvent.click( + await screen.findByRole("button", { name: "全部应用 1" }), + ); + await fireEvent.click(await screen.findByText("WPS")); + await fireEvent.click(await screen.findByRole("button", { name: "安装" })); + + await waitFor(() => { + expect(send).toHaveBeenCalledWith( + "queue-install", + expect.stringContaining('"pkgname":"wps"'), + ); + }); + expect(recordDownloadedApp).not.toHaveBeenCalled(); + + const queuedPayload = vi + .mocked(send) + .mock.calls.find( + ([channel]) => channel === "queue-install", + )?.[1] as string; + const queuedDownload = JSON.parse(queuedPayload) as { id: number }; + const completion: DownloadResult = { + id: queuedDownload.id, + time: Date.now(), + message: "installed", + success: true, + exitCode: 0, + status: "completed", + origin: "apm", + }; + + ipcHandlers.get("install-complete")?.({}, completion); + + await waitFor(() => { + expect(recordDownloadedApp).toHaveBeenCalledWith( + expect.objectContaining({ + appKey: "app:office:wps", + pkgname: "wps", + selectedOrigin: "apm", + }), + ); + }); + }); +}); diff --git a/src/__tests__/unit/AppDetailPage.test.ts b/src/__tests__/unit/AppDetailPage.test.ts index f758978e..36505fbd 100644 --- a/src/__tests__/unit/AppDetailPage.test.ts +++ b/src/__tests__/unit/AppDetailPage.test.ts @@ -1,8 +1,17 @@ import { fireEvent, render, screen } from "@testing-library/vue"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import AppDetailPage from "@/components/AppDetailPage.vue"; -import type { App } from "@/global/typedefinition"; +import type { App, ReviewTags } from "@/global/typedefinition"; + +vi.mock("@/components/ReviewsPanel.vue", () => ({ + default: { + name: "ReviewsPanel", + props: ["appKey", "tags", "loggedIn"], + template: + '
', + }, +})); const app: App = { name: "WPS", @@ -24,6 +33,40 @@ const app: App = { currentStatus: "not-installed", }; +const sparkApp: App = { + ...app, + name: "WPS Spark", + version: "2.0.0", + filename: "wps_2.0.0_amd64.deb", + origin: "spark", +}; + +const apmApp: App = { + ...app, + name: "WPS APM", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + origin: "apm", +}; + +const mergedApp: App = { + ...sparkApp, + isMerged: true, + sparkApp, + apmApp, + viewingOrigin: "spark", +}; + +const sparkTags: ReviewTags = { + origin: "spark", + category: "office", + pkgname: "wps", + version: "2.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", +}; + describe("AppDetailPage", () => { it("renders as page, emits back, and gates favorite for anonymous users", async () => { const rendered = render(AppDetailPage, { @@ -47,4 +90,42 @@ describe("AppDetailPage", () => { "收藏应用需要登录星火账号。", ); }); + + it("updates review identity when switching a merged app origin", async () => { + render(AppDetailPage, { + props: { + app: mergedApp, + screenshots: [], + sparkInstalled: false, + apmInstalled: false, + loggedIn: false, + reviewAppKey: "spark:amd64-store:office:wps", + reviewTags: sparkTags, + }, + }); + + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-app-key", + "spark:amd64-store:office:wps", + ); + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-origin", + "spark", + ); + + await fireEvent.click(screen.getByRole("button", { name: "APM" })); + + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-app-key", + "apm:amd64-apm:office:wps", + ); + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-origin", + "apm", + ); + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-version", + "1.0.0", + ); + }); }); diff --git a/src/__tests__/unit/ReviewsPanel.test.ts b/src/__tests__/unit/ReviewsPanel.test.ts index b8cf0f7a..fd6ef4d7 100644 --- a/src/__tests__/unit/ReviewsPanel.test.ts +++ b/src/__tests__/unit/ReviewsPanel.test.ts @@ -1,8 +1,29 @@ import { render, screen } from "@testing-library/vue"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import ReviewsPanel from "@/components/ReviewsPanel.vue"; -import type { ReviewTags } from "@/global/typedefinition"; +import { + fetchRatingSummary, + fetchReviews, + submitReview, +} from "@/modules/backendApi"; +import type { + AppReview, + RatingSummary, + ReviewTags, +} from "@/global/typedefinition"; + +const emptySummary: RatingSummary = { + averageRating: 0, + reviewCount: 0, + starCounts: {}, +}; + +vi.mock("@/modules/backendApi", () => ({ + fetchRatingSummary: vi.fn(async () => emptySummary), + fetchReviews: vi.fn(async () => []), + submitReview: vi.fn(), +})); const tags: ReviewTags = { origin: "apm", @@ -15,6 +36,14 @@ const tags: ReviewTags = { }; describe("ReviewsPanel", () => { + beforeEach(() => { + vi.mocked(fetchRatingSummary).mockReset(); + vi.mocked(fetchReviews).mockReset(); + vi.mocked(submitReview).mockReset(); + vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary); + vi.mocked(fetchReviews).mockResolvedValue([]); + }); + it("shows anonymous login prompt and read-only review tags", () => { render(ReviewsPanel, { props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false }, @@ -24,4 +53,88 @@ describe("ReviewsPanel", () => { expect(screen.getByText("1.0.0")).toBeTruthy(); expect(screen.getByText("deepin 25")).toBeTruthy(); }); + + it("ignores stale review responses after app key changes", async () => { + let resolveFirstSummary!: (summary: RatingSummary) => void; + let resolveFirstReviews!: (reviews: AppReview[]) => void; + let resolveSecondSummary!: (summary: RatingSummary) => void; + let resolveSecondReviews!: (reviews: AppReview[]) => void; + + vi.mocked(fetchRatingSummary) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirstSummary = resolve; + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveSecondSummary = resolve; + }), + ); + vi.mocked(fetchReviews) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirstReviews = resolve; + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveSecondReviews = resolve; + }), + ); + + const rendered = render(ReviewsPanel, { + props: { appKey: "first", tags, loggedIn: false }, + }); + + await rendered.rerender({ appKey: "second", tags, loggedIn: false }); + + resolveSecondSummary({ averageRating: 5, reviewCount: 1, starCounts: {} }); + resolveSecondReviews([ + { + id: 2, + rating: 5, + content: "second review", + version: tags.version, + packageArch: tags.packageArch, + clientArch: tags.clientArch, + distro: tags.distro, + origin: tags.origin, + category: tags.category, + createdAt: "2026-05-18T00:00:00Z", + updatedAt: "2026-05-18T00:00:00Z", + userDisplayName: "Second User", + userAvatarUrl: "", + }, + ]); + + expect(await screen.findByText("second review")).toBeTruthy(); + expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy(); + + resolveFirstSummary({ averageRating: 1, reviewCount: 1, starCounts: {} }); + resolveFirstReviews([ + { + id: 1, + rating: 1, + content: "first review", + version: tags.version, + packageArch: tags.packageArch, + clientArch: tags.clientArch, + distro: tags.distro, + origin: tags.origin, + category: tags.category, + createdAt: "2026-05-18T00:00:00Z", + updatedAt: "2026-05-18T00:00:00Z", + userDisplayName: "First User", + userAvatarUrl: "", + }, + ]); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(screen.getByText("second review")).toBeTruthy(); + expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy(); + expect(screen.queryByText("first review")).toBeNull(); + expect(screen.queryByText("1.0 / 5 (1)")).toBeNull(); + }); }); diff --git a/src/components/AppDetailPage.vue b/src/components/AppDetailPage.vue index ad710083..e3b26ca4 100644 --- a/src/components/AppDetailPage.vue +++ b/src/components/AppDetailPage.vue @@ -214,7 +214,11 @@ import { APM_STORE_BASE_URL, getHybridDefaultOrigin, } from "@/global/storeConfig"; -import { getDisplayApp } from "@/modules/appIdentity"; +import { + buildReviewAppKey, + buildReviewTags, + getDisplayApp, +} from "@/modules/appIdentity"; import type { App, ReviewTags } from "@/global/typedefinition"; const props = defineProps<{ @@ -299,6 +303,22 @@ const detailHtml = computed( () => displayApp.value?.more.replace(/\n/g, "
") ?? "", ); +const reviewAppKey = computed(() => { + if (!displayApp.value) return ""; + return buildReviewAppKey( + displayApp.value, + props.reviewTags?.clientArch ?? "amd64", + ); +}); + +const reviewTags = computed(() => { + if (!displayApp.value || !props.reviewTags) return null; + return buildReviewTags(displayApp.value, { + clientArch: props.reviewTags.clientArch, + distro: props.reviewTags.distro, + }); +}); + const selectOrigin = (origin: "spark" | "apm") => { viewingOrigin.value = origin; if (displayApp.value) emit("check-install", displayApp.value); diff --git a/src/components/ReviewsPanel.vue b/src/components/ReviewsPanel.vue index 2bd0ec10..eefc2382 100644 --- a/src/components/ReviewsPanel.vue +++ b/src/components/ReviewsPanel.vue @@ -145,6 +145,7 @@ const summary = ref(null); const loading = ref(false); const submitting = ref(false); const error = ref(""); +const loadGeneration = ref(0); const ratingText = computed(() => { if (!summary.value || summary.value.reviewCount === 0) return "暂无评分"; @@ -153,37 +154,50 @@ const ratingText = computed(() => { const loadReviews = async () => { if (!props.appKey) return; + const generation = loadGeneration.value + 1; + loadGeneration.value = generation; + const appKey = props.appKey; loading.value = true; error.value = ""; try { const [nextSummary, nextReviews] = await Promise.all([ - fetchRatingSummary(props.appKey), - fetchReviews(props.appKey), + fetchRatingSummary(appKey), + fetchReviews(appKey), ]); + if (generation !== loadGeneration.value || appKey !== props.appKey) return; summary.value = nextSummary; reviews.value = nextReviews; } catch (caught: unknown) { + if (generation !== loadGeneration.value || appKey !== props.appKey) return; error.value = (caught as Error)?.message || "加载评价失败"; } finally { - loading.value = false; + if (generation === loadGeneration.value && appKey === props.appKey) { + loading.value = false; + } } }; const submit = async () => { + const appKey = props.appKey; + const tags = props.tags; submitting.value = true; error.value = ""; try { - await submitReview(props.appKey, { + await submitReview(appKey, { rating: rating.value, content: content.value.trim(), - tags: props.tags, + tags, }); + if (appKey !== props.appKey) return; content.value = ""; await loadReviews(); } catch (caught: unknown) { + if (appKey !== props.appKey) return; error.value = (caught as Error)?.message || "发表评论失败"; } finally { - submitting.value = false; + if (appKey === props.appKey) { + submitting.value = false; + } } };