Add Outbox pattern for reliable message sending with offline recovery: - Persist pending/failed messages to SQLite (status field extended with 'pending'|'failed') - App restart scans pending messages and auto-retries failed sends - Add versioned migration system (PRAGMA user_version) for future schema evolution Implement three-tier sync strategy for precision and efficiency: - syncByVersion: precise incremental sync using persisted cursor (Matrix-style) - syncBySeq: fallback seq-based incremental sync - Full refresh: last resort for recovery Add conversation sync state machine for UI error visibility: - 'idle' | 'hydrating' | 'synced' | 'error' states exposed via useMessages hook - Enables loading/error indicators during hydration pipeline Standardize error handling across message services using createErrorHandler. Add tests: messageOutbox.test.ts, syncByVersion.test.ts, syncState.test.ts
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
/**
|
||
* syncByVersion 游标同步单测(批次二③)
|
||
*
|
||
* 锁定基于版本号的精确增量恢复不变量:
|
||
* 1. 成功同步并推进持久化游标
|
||
* 2. full_sync=true 时退化(返回 false)
|
||
* 3. 异常时退化(返回 false)
|
||
* 4. 无 version(<=0)时直接退化
|
||
*
|
||
* 对标 Matrix resume 游标:断线恢复从全量轮询升级为精确增量。
|
||
*/
|
||
|
||
import { useMessageStore } from '../store';
|
||
import { MessageSyncService } from '../services/MessageSyncService';
|
||
import { messageService } from '@/services/message';
|
||
import { messageRepository } from '@/database';
|
||
|
||
function createSyncService(): MessageSyncService {
|
||
return new MessageSyncService(
|
||
() => 'me',
|
||
{ markAsRead: async () => {}, markAllAsRead: async () => {} } as any,
|
||
{ getSenderInfo: async () => null, enrichMessagesWithSenderInfo: () => {} } as any,
|
||
undefined
|
||
);
|
||
}
|
||
|
||
describe('syncByVersion 游标同步', () => {
|
||
beforeEach(() => {
|
||
useMessageStore.getState().reset();
|
||
(messageRepository as any).__reset();
|
||
(messageService as any).__reset();
|
||
});
|
||
|
||
it('无变更时返回成功且推进游标', async () => {
|
||
const serviceMock = messageService as any;
|
||
serviceMock.__setSyncByVersionHandler((version: number) => ({
|
||
current_version: version + 10,
|
||
changes: [],
|
||
full_sync: false,
|
||
has_more: false,
|
||
}));
|
||
|
||
const sync = createSyncService();
|
||
const ok = await sync.syncByVersion(100);
|
||
|
||
expect(ok).toBe(true);
|
||
// 游标推进到 current_version
|
||
expect(useMessageStore.getState().syncVersion).toBe(110);
|
||
});
|
||
|
||
it('full_sync=true 时退化为 false', async () => {
|
||
const serviceMock = messageService as any;
|
||
serviceMock.__setSyncByVersionHandler(() => ({
|
||
current_version: 0,
|
||
changes: [],
|
||
full_sync: true,
|
||
has_more: false,
|
||
}));
|
||
|
||
const sync = createSyncService();
|
||
const ok = await sync.syncByVersion(100);
|
||
|
||
expect(ok).toBe(false);
|
||
});
|
||
|
||
it('API 抛异常时退化为 false', async () => {
|
||
const serviceMock = messageService as any;
|
||
serviceMock.__setSyncByVersionHandler(() => {
|
||
throw new Error('网络故障');
|
||
});
|
||
|
||
const sync = createSyncService();
|
||
const ok = await sync.syncByVersion(100);
|
||
|
||
expect(ok).toBe(false);
|
||
});
|
||
|
||
it('version <= 0 时直接退化', async () => {
|
||
const sync = createSyncService();
|
||
const ok1 = await sync.syncByVersion(0);
|
||
const ok2 = await sync.syncByVersion(-1);
|
||
expect(ok1).toBe(false);
|
||
expect(ok2).toBe(false);
|
||
});
|
||
|
||
it('有会话变更时按需同步会话详情', async () => {
|
||
const serviceMock = messageService as any;
|
||
// 先在 store 放一个会话,让 fetchConversationDetail 有上下文
|
||
useMessageStore.getState().setConversationsWithUnread(
|
||
new Map([['c1', { id: 'c1', last_seq: 5, unread_count: 0 } as any]]),
|
||
0, 0
|
||
);
|
||
|
||
let detailCalled = false;
|
||
serviceMock.__setSyncByVersionHandler((version: number) => ({
|
||
current_version: version + 5,
|
||
changes: [
|
||
{ conversation_id: 'c1', change_type: 'new_message', max_seq: 10, version: version + 1 },
|
||
],
|
||
full_sync: false,
|
||
has_more: false,
|
||
}));
|
||
// fetchConversationDetail 内部调 getConversationById
|
||
serviceMock.getConversationById = async () => {
|
||
detailCalled = true;
|
||
return { id: 'c1', last_seq: 10 } as any;
|
||
};
|
||
serviceMock.getMessages = async () => ({ messages: [], total: 0, page: 1, page_size: 20 });
|
||
|
||
const sync = createSyncService();
|
||
const ok = await sync.syncByVersion(100);
|
||
|
||
expect(ok).toBe(true);
|
||
expect(detailCalled).toBe(true);
|
||
expect(useMessageStore.getState().syncVersion).toBe(105);
|
||
});
|
||
});
|