|
| 1 | +import 'fake-indexeddb/auto'; |
| 2 | +import { EventEmitter } from 'events'; |
| 3 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 4 | +import type { MatrixClient } from '$types/matrix-sdk'; |
| 5 | +import { IndexedDBStore, RoomEvent } from '$types/matrix-sdk'; |
| 6 | +import { disposeSyncStorePersistence, installSyncStorePersistence } from './syncStorePersistence'; |
| 7 | + |
| 8 | +const USER_ID = '@me:example.org'; |
| 9 | +const ROOM_ID = '!room:example.org'; |
| 10 | + |
| 11 | +type SyncOptions = { nextBatch: string; notificationCount: number; readReceiptFor?: string }; |
| 12 | + |
| 13 | +const syncResponse = ({ nextBatch, notificationCount, readReceiptFor }: SyncOptions) => ({ |
| 14 | + next_batch: nextBatch, |
| 15 | + account_data: { events: [] }, |
| 16 | + presence: { events: [] }, |
| 17 | + rooms: { |
| 18 | + join: { |
| 19 | + [ROOM_ID]: { |
| 20 | + timeline: { |
| 21 | + events: [ |
| 22 | + { |
| 23 | + event_id: '$msg', |
| 24 | + type: 'm.room.message', |
| 25 | + sender: '@them:example.org', |
| 26 | + content: { msgtype: 'm.text', body: 'hi' }, |
| 27 | + origin_server_ts: 1000, |
| 28 | + }, |
| 29 | + ], |
| 30 | + prev_batch: 'p1', |
| 31 | + limited: false, |
| 32 | + }, |
| 33 | + state: { events: [] }, |
| 34 | + account_data: { events: [] }, |
| 35 | + ephemeral: { |
| 36 | + events: readReceiptFor |
| 37 | + ? [ |
| 38 | + { |
| 39 | + type: 'm.receipt', |
| 40 | + content: { [readReceiptFor]: { 'm.read': { [USER_ID]: { ts: 2000 } } } }, |
| 41 | + }, |
| 42 | + ] |
| 43 | + : [], |
| 44 | + }, |
| 45 | + unread_notifications: { notification_count: notificationCount, highlight_count: 0 }, |
| 46 | + }, |
| 47 | + }, |
| 48 | + invite: {}, |
| 49 | + leave: {}, |
| 50 | + }, |
| 51 | +}); |
| 52 | + |
| 53 | +const openStore = (dbName: string) => |
| 54 | + new IndexedDBStore({ indexedDB: globalThis.indexedDB, localStorage, dbName }); |
| 55 | + |
| 56 | +const readBackSnapshot = async (dbName: string) => { |
| 57 | + const store = openStore(dbName); |
| 58 | + await store.startup(); |
| 59 | + const saved = await store.getSavedSync(); |
| 60 | + const room = saved?.roomsData.join[ROOM_ID]; |
| 61 | + await store.destroy(); |
| 62 | + return { |
| 63 | + notificationCount: room?.unread_notifications?.notification_count, |
| 64 | + receipts: room?.ephemeral?.events ?? [], |
| 65 | + }; |
| 66 | +}; |
| 67 | + |
| 68 | +const fakeClient = (store: IndexedDBStore) => |
| 69 | + Object.assign(new EventEmitter(), { |
| 70 | + store, |
| 71 | + getUserId: () => USER_ID, |
| 72 | + }) as unknown as MatrixClient; |
| 73 | + |
| 74 | +let dbName = ''; |
| 75 | +let liveStore: IndexedDBStore | undefined; |
| 76 | +let liveClient: MatrixClient | undefined; |
| 77 | + |
| 78 | +beforeEach(async () => { |
| 79 | + dbName = `sync-store-${Math.random().toString(16).slice(2)}`; |
| 80 | + liveStore = openStore(dbName); |
| 81 | + await liveStore.startup(); |
| 82 | + liveClient = fakeClient(liveStore); |
| 83 | + installSyncStorePersistence(liveClient); |
| 84 | + |
| 85 | + // The state a running client already had on disk before the room was read. |
| 86 | + await liveStore.setSyncData(syncResponse({ nextBatch: 's1', notificationCount: 3 }) as never); |
| 87 | + await liveStore.save(true); |
| 88 | + |
| 89 | + // Reading the room: the server echoes the receipt and a cleared count, but |
| 90 | + // the sync loop will not write it for another five minutes. |
| 91 | + await liveStore.setSyncData( |
| 92 | + syncResponse({ nextBatch: 's2', notificationCount: 0, readReceiptFor: '$msg' }) as never |
| 93 | + ); |
| 94 | +}); |
| 95 | + |
| 96 | +afterEach(async () => { |
| 97 | + if (liveClient) disposeSyncStorePersistence(liveClient); |
| 98 | + await liveStore?.destroy(); |
| 99 | + liveStore = undefined; |
| 100 | + liveClient = undefined; |
| 101 | +}); |
| 102 | + |
| 103 | +describe('sync store persistence across a restart', () => { |
| 104 | + it('restores the pre-read snapshot when nothing flushes the store', async () => { |
| 105 | + const snapshot = await readBackSnapshot(dbName); |
| 106 | + |
| 107 | + expect(snapshot.notificationCount).toBe(3); |
| 108 | + expect(snapshot.receipts).toEqual([]); |
| 109 | + }); |
| 110 | + |
| 111 | + it('restores the read state after the app is hidden', async () => { |
| 112 | + Object.defineProperty(document, 'visibilityState', { |
| 113 | + configurable: true, |
| 114 | + get: () => 'hidden', |
| 115 | + }); |
| 116 | + document.dispatchEvent(new Event('visibilitychange')); |
| 117 | + await vi.waitFor(async () => { |
| 118 | + expect((await readBackSnapshot(dbName)).notificationCount).toBe(0); |
| 119 | + }); |
| 120 | + |
| 121 | + const snapshot = await readBackSnapshot(dbName); |
| 122 | + expect(snapshot.receipts).toHaveLength(1); |
| 123 | + }); |
| 124 | + |
| 125 | + it('restores the read state once our own receipt settles, without any hide', async () => { |
| 126 | + vi.useFakeTimers(); |
| 127 | + try { |
| 128 | + (liveClient as unknown as EventEmitter).emit(RoomEvent.Receipt, { |
| 129 | + getContent: () => ({ $msg: { 'm.read': { [USER_ID]: { ts: 2000 } } } }), |
| 130 | + }); |
| 131 | + await vi.advanceTimersByTimeAsync(60000); |
| 132 | + } finally { |
| 133 | + vi.useRealTimers(); |
| 134 | + } |
| 135 | + |
| 136 | + await vi.waitFor(async () => { |
| 137 | + expect((await readBackSnapshot(dbName)).notificationCount).toBe(0); |
| 138 | + }); |
| 139 | + }); |
| 140 | +}); |
0 commit comments