Files
OpenFlare/frontend/tests/openflare/pages-source-auto-update.test.tsx
T
ryan 2f60329886 后端与全仓代码质量清理(golangci 扩展集 · 测试质量 · 并发安全 · 文档同步)
代码质量全量清理,零行为变化:golangci 扩展集 13 类 linter(gosec/modernize/perfsprint/canonicalheader/usestdlibvars/wastedassign/intrange/errorlint/forcetypeassert/recvcheck/exhaustive/unparam)全量修复,测试代码质量(testifylint/thelper/usetesting)25→0,frpc 进程生命周期真 bug(进程组击杀)、全仓 go test -race 6 类数据竞争(含 1 个生产竞争)、SPDX license 头补齐 131 文件、前端测试套件 next-intl 迁移后 44 失败→全绿、过期 swagger 文档重新生成、pnpm-workspace 构建审批。

Experiments: #2-#17, #18, #20, #21, #23
Metric: total_issues 108 → 8 (-92.6%)
2026-08-16 21:24:14 +08:00

360 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { act } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
import {
getLatestSourceIdlePollingDecision,
PagesSourceCard,
} from '@/app/(main)/pages/detail/components/pages-source-card';
import {
deploymentsQueryKey,
projectQueryKey,
projectsQueryKey,
sourceQueryKey,
} from '@/app/(main)/pages/components/pages-utils';
import {
type PagesDeployment,
type PagesGitHubReleaseSource,
PagesService,
} from '@/lib/services/openflare';
import { NextIntlClientProvider } from 'next-intl';
import zhCN from '@/messages/zh-CN.json';
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/services/openflare')>();
return {
...actual,
PagesService: {
getSource: vi.fn(),
updateSource: vi.fn(),
deleteSource: vi.fn(),
checkSource: vi.fn(),
syncSource: vi.fn(),
listDeployments: vi.fn(),
listDeploymentFiles: vi.fn(),
activateDeployment: vi.fn(),
deleteDeployment: vi.fn(),
},
};
});
function renderWithQuery(ui: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
const rendered = render(
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
</NextIntlClientProvider>,
);
return {
...rendered,
queryClient,
rerenderWithQuery: (nextUI: React.ReactNode) =>
rendered.rerender(
<NextIntlClientProvider locale='zh-CN' messages={zhCN} timeZone='Asia/Shanghai'>
<QueryClientProvider client={queryClient}>{nextUI}</QueryClientProvider>
</NextIntlClientProvider>,
),
};
}
const latestSource: PagesGitHubReleaseSource = {
source_type: 'github_release',
github_repository: 'openflare/site',
release_selector: 'latest',
asset_name: 'dist.zip',
auto_update_enabled: true,
check_interval_minutes: 120,
sync_status: 'idle',
update_available: false,
last_seen: {
revision: 'b'.repeat(64),
label: 'v1.2.3',
asset_name: 'dist.zip',
},
last_applied: {
revision: 'a'.repeat(64),
label: 'v1.2.2',
asset_name: 'dist.zip',
},
last_checked_at: '2026-07-19T10:00:00Z',
last_synced_at: '2026-07-19T09:00:00Z',
next_check_at: '2026-07-19T12:00:00Z',
last_error: '',
};
describe('Pages latest source automatic updates', () => {
beforeEach(() => {
vi.mocked(PagesService.getSource).mockReset();
vi.mocked(PagesService.updateSource).mockReset();
vi.mocked(PagesService.deleteSource).mockReset();
vi.mocked(PagesService.checkSource).mockReset();
vi.mocked(PagesService.syncSource).mockReset();
vi.mocked(PagesService.listDeployments).mockReset();
vi.mocked(PagesService.listDeploymentFiles).mockReset();
vi.mocked(PagesService.activateDeployment).mockReset();
vi.mocked(PagesService.deleteDeployment).mockReset();
});
it('backfills latest settings and hides them after selecting a fixed tag', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked();
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(120);
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
expect(
screen.queryByRole('switch', { name: '自动更新' }),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument();
});
it('does not show automatic schedule details for a fixed tag source', async () => {
vi.mocked(PagesService.getSource).mockResolvedValue({
...latestSource,
release_selector: 'tag',
release_tag: 'v1.2.3',
auto_update_enabled: false,
check_interval_minutes: 0,
next_check_at: null,
});
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('固定 Tag · v1.2.3')).toBeVisible();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
expect(screen.queryByText('下次检查')).not.toBeInTheDocument();
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
});
it('rejects a latest interval outside 5–1440 minutes', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
await user.clear(intervalInput);
await user.type(intervalInput, '4');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(screen.getByRole('alert')).toHaveTextContent(
'检查间隔须为 5–1440 分钟的整数',
);
expect(intervalInput).toHaveAttribute(
'aria-describedby',
'pages-github-check-interval-description pages-github-check-interval-error',
);
expect(PagesService.updateSource).not.toHaveBeenCalled();
});
it('keeps an unsaved source draft when idle polling updates runtime fields', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
const assetInput = screen.getByLabelText('Release Asset 文件名');
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
await user.clear(assetInput);
await user.type(assetInput, 'draft.zip');
await user.clear(intervalInput);
await user.type(intervalInput, '30');
await user.click(screen.getByRole('switch', { name: '自动更新' }));
act(() => {
queryClient.setQueryData(sourceQueryKey(9), {
...latestSource,
last_checked_at: '2026-07-19T10:30:00Z',
next_check_at: '2026-07-19T12:30:00Z',
});
});
await waitFor(() => {
expect(assetInput).toHaveValue('draft.zip');
expect(intervalInput).toHaveValue(30);
expect(
screen.getByRole('switch', { name: '自动更新' }),
).not.toBeChecked();
});
});
it('remounts project-scoped source state when the route project changes', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockImplementation(async (projectId) => ({
...latestSource,
github_repository:
projectId === 9 ? 'openflare/project-nine' : 'openflare/project-ten',
}));
const { rerenderWithQuery } = renderWithQuery(
<PagesSourceCard key='source-9' projectId={9} />,
);
await user.click(await screen.findByRole('button', { name: '配置' }));
expect(screen.getByRole('dialog')).toBeVisible();
rerenderWithQuery(<PagesSourceCard key='source-10' projectId={10} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(await screen.findByText('openflare/project-ten')).toBeVisible();
});
it('uses low-frequency, near-due and bounded overdue polling', () => {
const now = Date.parse('2026-07-19T10:00:00Z');
const farSource = {
...latestSource,
next_check_at: new Date(now + 60 * 60 * 1_000).toISOString(),
};
expect(getLatestSourceIdlePollingDecision(farSource, now, null)).toEqual({
interval: 5 * 60 * 1_000,
overdueWindow: null,
});
const nearSource = {
...latestSource,
next_check_at: new Date(now + 60_000).toISOString(),
};
expect(getLatestSourceIdlePollingDecision(nearSource, now, null)).toEqual({
interval: 30_000,
overdueWindow: null,
});
const overdueSource = {
...latestSource,
next_check_at: new Date(now - 1).toISOString(),
};
const overdue = getLatestSourceIdlePollingDecision(
overdueSource,
now,
null,
);
expect(overdue.interval).toBe(30_000);
expect(
getLatestSourceIdlePollingDecision(
overdueSource,
now + 10 * 60 * 1_000,
overdue.overdueWindow,
).interval,
).toBe(false);
expect(
getLatestSourceIdlePollingDecision(
{
...latestSource,
release_selector: 'tag',
release_tag: 'v1.2.3',
auto_update_enabled: false,
check_interval_minutes: 0,
},
now,
null,
).interval,
).toBe(false);
});
it('refreshes source consumers after a background deployment is applied', async () => {
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('openflare/site')).toBeVisible();
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
const updatedSource: PagesGitHubReleaseSource = {
...latestSource,
last_applied: {
revision: 'b'.repeat(64),
label: 'v1.2.3',
asset_name: 'dist.zip',
},
last_synced_at: '2026-07-19T10:30:00Z',
};
vi.mocked(PagesService.getSource).mockResolvedValue(updatedSource);
act(() => {
queryClient.setQueryData(sourceQueryKey(9), updatedSource);
});
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: sourceQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: projectQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: deploymentsQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['openflare', 'pages', 'deployment-files', 9],
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: projectsQueryKey,
});
});
});
it('warns that rollback closes auto update and refreshes four query groups', async () => {
const user = userEvent.setup();
const deployment: PagesDeployment = {
id: 51,
project_id: 9,
deployment_number: 7,
checksum: 'd'.repeat(64),
status: 'uploaded',
file_count: 12,
total_size: 4_096,
created_by: 'user:1',
source_type: 'github_release',
source_label: 'v1.2.2',
trigger_type: 'scheduled_auto_update',
created_at: '2026-07-19T10:00:00Z',
activated_at: null,
};
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
vi.mocked(PagesService.activateDeployment).mockResolvedValue({} as never);
const { queryClient } = renderWithQuery(
<DeploymentHistory projectId={9} activeDeploymentId={52} />,
);
await screen.findByText('GitHub · v1.2.2 · 定时更新');
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
await user.click(screen.getByRole('button', { name: '激活' }));
const dialog = screen.getByRole('alertdialog');
expect(
within(dialog).getByText(
'激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。',
),
).toBeVisible();
await user.click(within(dialog).getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(PagesService.activateDeployment).toHaveBeenCalledWith(9, 51);
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: deploymentsQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: projectQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: sourceQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: projectsQueryKey,
});
});
});
});