Files
frontend/src/stores/message/__tests__/syncByVersion.test.ts
lafay f9b0999112
Some checks failed
Frontend CI / build-android-apk (push) Waiting to run
Frontend CI / ota (android) (push) Successful in 1m52s
Frontend CI / ota (ios) (push) Successful in 2m36s
Frontend CI / build-and-push-web (push) Failing after 1m15s
feat(messaging): implement Outbox persistence with three-tier sync strategy
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
2026-06-28 23:19:52 +08:00

118 lines
3.6 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.
/**
* 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);
});
});