Files
photo-live/apps/viewer/src/__tests__/activity.spec.ts
T
浅念 bfba76b5e6 fix(ads): 终审修复——staging 前缀+运行时上限核验/失效绑定编辑/白名单去 heic/杂项
- verifyStaging 硬化(services/api/src/ads/ads.service.ts):新增键前缀核验
  (仅 OBJECT_KEY_ROOT/ads/staging/ 布局,防照片域任意键被提升为公开广告)
  与运行时 2MB 上限(head.bytes > AD_UPLOAD_MAX_BYTES → 400 报文含限额值
  ——init 申明管不住实际 PUT 体,create/update 是唯一核验点);e2e ⑰ 以
  测试 storage 直接 presignPut 真实对象覆盖两分支
- 失效绑定编辑 400(admin AdsFormDialog):PATCH 仅在 albumIds 实际变化时
  携带(albumIdsChanged 序敏感深比较,纯函数落 adsLogic + vitest 用例);
  preloadOptions 解析失败的绑定 ID 加「已失效,可移除」后缀(zh/en 新增
  adsManage.form.staleAlbum)——软删绑定不再挡住其它字段保存
- 白名单去 heic(原图直出无变体兜底,Chromium/Firefox 不渲染=破图):
  contracts 三处 enum 收窄 [image/jpeg, image/png] 并重生成类型;dto 定义
  广告域独立 AD_IMAGE_CONTENT_TYPES(与照片域 UPLOAD_CONTENT_TYPES 分离),
  AD_IMAGE_EXTENSIONS 由其派生;init 对 .heic 文件名以广告域集合二次核验
  拒绝(共享 extOfFilename 仍认 heic,杜绝 init 放行→create 中段断裂);
  e2e heic 矩阵 400;admin accept/文案/类型同步收窄
- searchAlbums 补 catch(http 层已弹 toast,本地静默保持旧选项)
- ActivityPage 关闭记忆测试补位(activity.spec):dismissed 不拉取不渲染、
  close 写 sessionStorage 并清空轮播两用例(getPublicAds 入替身防真实 fetch)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 02:20:21 +08:00

313 lines
13 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 { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import App from '../App.vue';
import {
ApiError,
getPublicAds,
getPublicAlbum,
getPublicPhotos,
type PublicAd,
type PublicAlbum,
} from '../api/client';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import ActivityPage from '../pages/ActivityPage.vue';
import { router } from '../router';
import { makeAlbum, makeEnAlbum } from './fixtures';
// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum /
// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch),
// 保证 ApiError/errors 映射等真实逻辑参与断言。Task 10 起活动页组装 WS/浏览
// 上报:RealtimeClient 以无操作桩替换(jsdom WebSocket 会发起真实连接)、
// postAlbumVisit 替身防真实 fetch;广告位轮播(Task 10)getPublicAds 同样
// 替身(相册加载成功后页面会独立拉取广告,不替身会发起真实 fetch)。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return {
...actual,
getPublicAlbum: vi.fn(),
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
getPublicAds: vi.fn().mockResolvedValue({ items: [] }),
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
};
});
vi.mock('../realtime/ws', () => {
class RealtimeClientStub {
start(): void {}
stop(): void {}
}
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
});
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
const mockedGetPublicAds = vi.mocked(getPublicAds);
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的
// locale watch 也会在后续用例切换语言时再度触发取数,污染调用序断言。
let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
function mountPage(): VueWrapper {
const wrapper = mount(ActivityPage, { global: { plugins: [router, i18n] } });
mounted.push(wrapper);
return wrapper;
}
function mountApp(): VueWrapper {
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
mounted.push(wrapper);
return wrapper;
}
/** UI-02 V01 紧凑语言控件:先展开菜单再点目标语言选项(SUPPORTED_LOCALES 序)。 */
async function pickLanguage(wrapper: VueWrapper, option: '中文' | 'English'): Promise<void> {
await wrapper.find('.locale-trigger').trigger('click');
const target = wrapper.findAll('.locale-option').find((btn) => btn.text() === option);
expect(target).toBeDefined();
await target!.trigger('click');
}
describe('activity page (Task 6)', () => {
beforeEach(async () => {
localStorage.clear();
sessionStorage.clear();
i18n = createViewerI18n('zh-CN');
document.title = '';
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
mockedGetPublicAds.mockReset().mockResolvedValue({ items: [] });
await router.push('/a/tok-activity');
await router.isReady();
});
afterEach(() => {
while (mounted.length > 0) {
mounted.pop()?.unmount();
}
});
it('renders album info in zh: title, pre-wrap description, venue · address, categories chips and empty notice', async () => {
const wrapper = mountPage();
await flushPromises();
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-activity', 'zh-CN');
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
expect(wrapper.find('.description').text()).toBe('年度盛会\n多行介绍');
expect(wrapper.find('.meta').text()).toContain('大宴会厅 · 上海市浦东新区世纪大道 100 号');
// Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01 10:00/20:00。
expect(wrapper.find('.meta').text()).toContain('10月1日');
const chips = wrapper.findAll('.categories .chip');
expect(chips.map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']);
expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true);
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
// 无 banner 资产 → 不渲染 img;viewCount 未知(null)→ 浏览项显示「—」而非 0(UI-02 §4.1)。
// (参考稿:浏览项前置 ◉ Unicode 图标,属渲染内容的一部分。)
expect(wrapper.find('img.banner').exists()).toBe(false);
expect(wrapper.find('.view-count').exists()).toBe(false); // 已隐藏(2026-09-24)
expect(document.title).toBe('2026 公司年会');
});
it('formats the time range in the album timezone (America/New_York shifts UTC date back one day)', async () => {
mockedGetPublicAlbum.mockResolvedValue(
makeAlbum({ timezone: 'America/New_York' }),
);
const wrapper = mountPage();
await flushPromises();
// UTC 10-01 02:00 → New York(EDT, UTC-4)09-30 22:00;endAt 12:00Z → 10-01 08:00。
const time = wrapper.find('.meta').text();
expect(time).toContain('9月30');
expect(time).toContain('10月1');
expect(time).not.toContain('9月29');
});
it('renders a single date when endAt is missing (no range separator)', async () => {
mockedGetPublicAlbum.mockResolvedValue(
makeAlbum({ timezone: 'America/New_York', endAt: null }),
);
const wrapper = mountPage();
await flushPromises();
const time = wrapper.find('.meta').text();
expect(time).toContain('9月30');
expect(time).not.toContain('–'); // 区间分隔符不应出现
expect(time).not.toContain('10月1');
});
it('switching locale re-fetches with ?locale= and shows resolved English content', async () => {
mockedGetPublicAlbum.mockImplementation((_token, locale) =>
Promise.resolve(locale === 'en' ? makeEnAlbum() : makeAlbum()),
);
const wrapper = mountApp();
await flushPromises();
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'zh-CN');
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
await pickLanguage(wrapper, 'English');
await flushPromises();
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'en');
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
expect(wrapper.find('.empty-notice').text()).toBe('Photos coming soon');
expect(document.title).toBe('Annual Party 2026');
});
it('ALBUM_NOT_PUBLIC renders the localized message with retry; retry recovers', async () => {
mockedGetPublicAlbum
.mockRejectedValueOnce(new ApiError('ALBUM_NOT_PUBLIC', 'album not public', 404, 'req-1'))
.mockResolvedValueOnce(makeAlbum());
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('.error[role="alert"]').text()).toContain('相册不存在或已下线');
expect(wrapper.find('h1.title').exists()).toBe(false);
await wrapper.find('.error button').trigger('click');
await flushPromises();
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
});
it('renders banner img with the signed url when present, nothing when absent', async () => {
mockedGetPublicAlbum.mockResolvedValue(
makeAlbum({ banner: { url: 'http://127.0.0.1:9000/photo-live/dev/albums/a1/assets/banner.jpg?sig=1', expiresAt: '2026-10-01T00:00:00.000Z' } }),
);
const withBanner = mountPage();
await flushPromises();
const img = withBanner.find('img.banner');
expect(img.exists()).toBe(true);
expect(img.attributes('src')).toContain('/albums/a1/assets/banner.jpg?sig=1');
mockedGetPublicAlbum.mockResolvedValue(makeAlbum());
const withoutBanner = mountPage();
await flushPromises();
expect(withoutBanner.find('img.banner').exists()).toBe(false);
});
it('hides the banner on img load failure (layout adapts, no broken image)', async () => {
mockedGetPublicAlbum.mockResolvedValue(
makeAlbum({ banner: { url: 'http://127.0.0.1:9000/photo-live/dev/expired.jpg?sig=stale', expiresAt: '2020-01-01T00:00:00.000Z' } }),
);
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('img.banner').exists()).toBe(true);
await wrapper.find('img.banner').trigger('error');
await nextTick();
expect(wrapper.find('img.banner').exists()).toBe(false);
});
it('shows the localized loading state until the request settles', async () => {
let resolve!: (value: PublicAlbum) => void;
mockedGetPublicAlbum.mockReturnValue(
new Promise<PublicAlbum>((res) => {
resolve = res;
}),
);
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('.status').text()).toBe('加载中…');
expect(wrapper.find('h1.title').exists()).toBe(false);
resolve(makeAlbum());
await flushPromises();
expect(wrapper.find('.status').exists()).toBe(false);
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
});
it('discards stale responses when the locale changes quickly (last request wins)', async () => {
const pending: Array<(value: PublicAlbum | PromiseLike<PublicAlbum>) => void> = [];
mockedGetPublicAlbum.mockImplementation(
() =>
new Promise<PublicAlbum>((res) => {
pending.push(res);
}),
);
const wrapper = mountPage();
await flushPromises();
// zh 请求在途时切到 en(触发第二个请求),en 先返回、zh 后返回——zh 为过期响应。
i18n.global.locale.value = 'en';
await flushPromises();
expect(pending).toHaveLength(2);
pending[1](makeEnAlbum());
await flushPromises();
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
pending[0](makeAlbum());
await flushPromises();
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
});
// —— UI-02 V02:排序/分类经 AlbumHeader 上抛,沿用本页序列重置机制 ——
it('sort tabs removed per user request (2026-09-23): no popular tab, sequence stays latest', async () => {
const wrapper = mountPage();
await flushPromises();
expect(wrapper.findAll('.sort-tab')).toHaveLength(0);
expect(wrapper.find('.sort-tabs').exists()).toBe(false);
});
it('category chips filter the sequence and the selection survives a locale switch', async () => {
const wrapper = mountPage();
await flushPromises();
mockedGetPublicPhotos.mockClear();
await wrapper.findAll('.categories .chip')[1].trigger('click'); // 「签到墙」= cat-sign
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-activity', expect.objectContaining({ category: 'cat-sign' }));
mockedGetPublicAlbum.mockImplementation((_token, locale) =>
Promise.resolve(locale === 'en' ? makeEnAlbum() : makeAlbum()),
);
i18n.global.locale.value = 'en';
await flushPromises();
// 语言只换显示与内容请求,分类 id 跨语言保留(§4.2)。
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
'tok-activity',
expect.objectContaining({ category: 'cat-sign', locale: 'en' }),
);
const chips = wrapper.findAll('.categories .chip');
expect(chips[1].classes()).toContain('active');
expect(chips[1].text()).toBe('Check-in Wall');
});
// —— 广告位轮播(Task 10 终审补位):会话级关闭记忆在页面(组件只 emit
// close,无 sessionStorage 副作用)—— dismissed 时不拉取不渲染、
// close 后写键并清空,两段都在页面级守护 ——
function makeAd(adId: string): PublicAd {
return { adId, imageUrl: `https://ad.test/${adId}.jpg`, linkUrl: `https://link.test/${adId}` };
}
it('skips the ads request and carousel when the session dismissed flag is set', async () => {
sessionStorage.setItem('viewer.adsDismissed', '1');
mockedGetPublicAds.mockResolvedValue({ items: [makeAd('dismissed-ad')] });
const wrapper = mountPage();
await flushPromises();
// 命中会话级记忆 → 连请求都省(设计:关闭后本会话不再拉取)。
expect(mockedGetPublicAds).not.toHaveBeenCalled();
expect(wrapper.find('.ad-carousel').exists()).toBe(false);
});
it('dismissing the carousel writes the sessionStorage flag and clears it from the page', async () => {
mockedGetPublicAds.mockResolvedValue({ items: [makeAd('ad-1')] });
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('.ad-carousel').exists()).toBe(true);
await wrapper.get('.ad-close').trigger('click');
await nextTick();
// 页面 close 处理器:写会话级键 + 清空数据(轮播整块缺席)。
expect(sessionStorage.getItem('viewer.adsDismissed')).toBe('1');
expect(wrapper.find('.ad-carousel').exists()).toBe(false);
});
});