cloud-drive-renamer/tests/unit/adapters/onedrive-adapter.test.ts
Zain 1176d08224 feat(onedrive): 新增 OneDrive 个人版支持,更新第 13 个平台文案与键盘隔离
新增 OneDrive 个人版适配器、MAIN 桥接、名称校验与冲突检测、目录身份与分页遍历。同步更新三语简介、商店素材、仓库描述及
manifest 域名注册。修复面板键盘冒泡至宿主页面的隔离与工具栏 Escape 作用域。修正 OneDrive 同步结果语义为手动刷新提示,
避免误报同步失败。补全对应单测与浏览器验收脚本。
2026-09-27 17:31:44 +08:00

157 行
6.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OneDriveAdapter } from '../../../src/adapters/onedrive/adapter';
import { OneDrivePageScriptInjector } from '../../../src/adapters/onedrive/page-script-injector';
const uniqueId = '33333333-3333-3333-3333-333333333333';
const siteId = '11111111-1111-1111-1111-111111111111';
const listId = '22222222-2222-2222-2222-222222222222';
function fileItem(name = 'original.txt') {
return {
id: 'hex!item-1',
name,
size: 12,
eTag: '"item-etag"',
parentReference: { id: 'hex!folder', driveId: 'drive-1' },
sharepointIds: { siteId, listId, listItemUniqueId: uniqueId },
file: { mimeType: 'text/plain' },
};
}
function setLocation(search = '') {
vi.stubGlobal('window', {
location: {
href: `https://onedrive.live.com/my${search}`,
origin: 'https://onedrive.live.com',
pathname: '/my',
search,
},
});
}
function directoryResponse(id = 'hex!folder') {
return {
item: { id, parentReference: { driveId: 'drive-1' } },
scopeKey: `/personal/account:${id}`,
isRoot: false,
pathLength: 35,
};
}
function mockListedPages(pages: unknown[][] = [[fileItem()]]) {
return vi.spyOn(OneDrivePageScriptInjector.prototype, 'call').mockImplementation(async (operation, options = {}) => {
if (operation === 'directory') return { data: directoryResponse() };
if (operation === 'children') {
const pageIndex = options.continuation ? 1 : 0;
return { data: { value: pages[pageIndex] ?? [] } };
}
throw new Error(`Unexpected operation ${operation}`);
});
}
describe('OneDriveAdapter', () => {
beforeEach(() => setLocation());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('reports manual refresh without pretending to automatically sync the website', async () => {
const call = vi.spyOn(OneDrivePageScriptInjector.prototype, 'call');
expect(await new OneDriveAdapter().syncAfterRename()).toEqual({
success: false,
method: 'manual-refresh',
});
expect(call).not.toHaveBeenCalled();
});
it('follows validated pagination and rejects repeated continuation links', async () => {
const mock = vi.spyOn(OneDrivePageScriptInjector.prototype, 'call').mockImplementation(async (operation, options = {}) => {
if (operation === 'directory') return { data: directoryResponse() };
if (operation === 'children' && !options.continuation) {
return { data: { value: [fileItem()], '@odata.nextLink': '/personal/account/_api/v2.0/drive/items/hex!folder/children?$skiptoken=next' } };
}
if (operation === 'children') return { data: { value: [{ ...fileItem(), id: 'hex!item-2', sharepointIds: { siteId, listId, listItemUniqueId: '44444444-4444-4444-4444-444444444444' } }] } };
throw new Error(`Unexpected operation ${operation}`);
});
const adapter = new OneDriveAdapter();
expect(await adapter.getAllFiles()).toHaveLength(2);
expect(mock.mock.calls.filter(([operation]) => operation === 'children')).toHaveLength(2);
mock.mockImplementation(async (operation, options = {}) => {
if (operation === 'directory') return { data: directoryResponse() };
if (operation === 'children') return { data: { value: [], '@odata.nextLink': options.continuation ?? '/personal/account/_api/v2.0/drive/root/children?$skiptoken=loop' } };
throw new Error(`Unexpected operation ${operation}`);
});
await expect(adapter.getAllFiles()).rejects.toThrow(/repeated continuation/i);
});
it('rejects a parent from the old directory after the route changes', async () => {
mockListedPages();
const adapter = new OneDriveAdapter();
const files = await adapter.getAllFiles();
setLocation('?id=%2Fpersonal%2Faccount%2FDocuments%2FOther');
vi.spyOn(OneDrivePageScriptInjector.prototype, 'call').mockResolvedValue({ data: directoryResponse('hex!other') });
await expect(adapter.getAllFiles(files[0].parentId)).rejects.toThrow(/directory changed/i);
});
it('keeps the original FileItem intact after a successful rename for undo records', async () => {
const call = mockListedPages();
const adapter = new OneDriveAdapter();
const [file] = await adapter.getAllFiles();
call.mockImplementation(async (operation) => {
if (operation === 'rename') return { data: { name: 'renamed.txt' } };
if (operation === 'item') return { data: fileItem('renamed.txt') };
throw new Error(`Unexpected operation ${operation}`);
});
const result = await adapter.renameFile(file.id, 'renamed.txt');
expect(result).toMatchObject({ success: true, newName: 'renamed.txt' });
expect(file.name).toBe('original.txt');
expect((await adapter.getFileInfo(file.id)).name).toBe('renamed.txt');
});
it('allows a case-only rename of the same file while detecting another item collision', async () => {
const call = mockListedPages();
const adapter = new OneDriveAdapter();
const [file] = await adapter.getAllFiles();
expect(await adapter.checkNameConflict('ORIGINAL.TXT', file.parentId, file.id)).toBe(false);
expect(await adapter.checkNameConflict('ORIGINAL.TXT', file.parentId)).toBe(true);
call.mockImplementation(async (operation) => operation === 'rename' ? { data: { name: 'ORIGINAL.TXT' } } : Promise.reject(new Error(`Unexpected operation ${operation}`)));
expect((await adapter.renameFile(file.id, 'ORIGINAL.TXT')).success).toBe(true);
});
it('honors Retry-After and verifies an ambiguous result before any retry', async () => {
const call = mockListedPages();
const adapter = new OneDriveAdapter();
const [file] = await adapter.getAllFiles();
const sleep = vi.spyOn(adapter as unknown as { sleep: (ms: number) => Promise<void> }, 'sleep').mockResolvedValue(undefined);
let renameCount = 0;
call.mockImplementation(async (operation) => {
if (operation === 'rename') {
renameCount++;
if (renameCount === 1) throw Object.assign(new Error('HTTP 429'), { status: 429, headers: { 'retry-after': '0.001' } });
return { data: { name: 'renamed.txt' } };
}
if (operation === 'item') return { data: fileItem() };
throw new Error(`Unexpected operation ${operation}`);
});
expect((await adapter.renameFile(file.id, 'renamed.txt')).success).toBe(true);
expect(sleep).toHaveBeenCalledWith(1);
expect(renameCount).toBe(2);
renameCount = 0;
call.mockImplementation(async (operation) => {
if (operation === 'directory') return { data: directoryResponse() };
if (operation === 'children') return { data: { value: [fileItem('renamed.txt')] } };
if (operation === 'rename') {
renameCount++;
throw new Error('OneDrive API request timeout');
}
if (operation === 'item') return { data: fileItem('renamed.txt') };
throw new Error(`Unexpected operation ${operation}`);
});
expect((await adapter.renameFile(file.id, 'second-name.txt')).success).toBe(false);
expect(renameCount).toBe(1);
});
});