bfba76b5e6
- 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>
313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
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);
|
||
});
|
||
});
|