Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,24 @@ async function gracefulShutdown(signal) {
}
}

let shutdownHandlersRegistered = false;

function registerShutdownHandlers() {
if (shutdownHandlersRegistered) {
return;
}

const handleSignal = (signal) => {
gracefulShutdown(signal).catch((error) => {
console.error(`Failed to shut down after ${signal}:`, error);
});
};

process.on('SIGTERM', () => handleSignal('SIGTERM'));
process.on('SIGINT', () => handleSignal('SIGINT'));
shutdownHandlersRegistered = true;
}


async function startServer() {
try {
Expand Down Expand Up @@ -468,6 +486,7 @@ module.exports = {
server,
io,
startServer,
registerShutdownHandlers,
initializePersistence,
updateRoomMembers,
gracefulShutdown,
Expand All @@ -480,5 +499,6 @@ module.exports = {
};

if (require.main === module) {
registerShutdownHandlers();
startServer();
}
14 changes: 13 additions & 1 deletion apps/api/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ describe('server sync flow', () => {
let startServer;
let gracefulShutdown;
let handleSocketConnection;
let registerShutdownHandlers;

const roomId = 'valid-room-12345';

beforeEach(() => {
jest.resetModules();
process.env.PORT = '3102';
({ app, server, stores, startServer, gracefulShutdown, handleSocketConnection } = require('./index'));
({ app, server, stores, startServer, gracefulShutdown, handleSocketConnection, registerShutdownHandlers } = require('./index'));
stores.chainStore.clear();
stores.socketMeta.clear();
stores.chunkStore.clear();
Expand Down Expand Up @@ -145,4 +146,15 @@ describe('server sync flow', () => {
version: 99,
}));
});

test('registerShutdownHandlers wires SIGINT and SIGTERM', () => {
const onSpy = jest.spyOn(process, 'on').mockImplementation(() => process);

registerShutdownHandlers();

expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));

onSpy.mockRestore();
});
});
35 changes: 30 additions & 5 deletions apps/api/src/persistence/RedisPersistence.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,33 @@ class RedisPersistence extends PersistenceAdapter {
return `${this.options.keyPrefix}log:${roomId}`;
}

/**
* 使用 SCAN 非阻塞遍历匹配的 key
* @param {string} pattern
* @returns {Promise<string[]>}
* @private
*/
async _scanKeys(pattern) {
const keys = [];
let cursor = '0';

do {
const reply = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
let batch = [];

if (Array.isArray(reply)) {
[cursor, batch] = reply;
} else {
cursor = reply?.cursor ?? '0';
batch = reply?.keys ?? [];
}

keys.push(...batch);
} while (cursor !== '0' && cursor !== 0);

return keys;
}

/**
* 保存同步链数据
* @param {string} roomId - 房间ID
Expand Down Expand Up @@ -199,9 +226,8 @@ class RedisPersistence extends PersistenceAdapter {
let deletedCount = 0;

try {
// 扫描所有房间 key
const roomPattern = `${this.options.keyPrefix}room:*`;
const keys = await this.client.keys(roomPattern);
const keys = await this._scanKeys(roomPattern);

for (const key of keys) {
try {
Expand Down Expand Up @@ -354,9 +380,8 @@ class RedisPersistence extends PersistenceAdapter {
const info = await this.client.info('memory');
const keyCount = await this.client.dbSize();

// 统计房间和日志数量
const roomKeys = await this.client.keys(`${this.options.keyPrefix}room:*`);
const logKeys = await this.client.keys(`${this.options.keyPrefix}log:*`);
const roomKeys = await this._scanKeys(`${this.options.keyPrefix}room:*`);
const logKeys = await this._scanKeys(`${this.options.keyPrefix}log:*`);

return {
connected: this.isConnected,
Expand Down
41 changes: 41 additions & 0 deletions apps/api/src/persistence/__tests__/RedisPersistence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ jest.mock('redis', () => ({
get: jest.fn().mockResolvedValue(null),
del: jest.fn().mockResolvedValue(1),
keys: jest.fn().mockResolvedValue([]),
scan: jest.fn().mockResolvedValue(['0', []]),
hSet: jest.fn().mockResolvedValue('OK'),
hGetAll: jest.fn().mockResolvedValue({}),
hGet: jest.fn().mockResolvedValue(null),
expire: jest.fn().mockResolvedValue(1),
info: jest.fn().mockResolvedValue(''),
dbSize: jest.fn().mockResolvedValue(0),
Expand Down Expand Up @@ -86,6 +88,45 @@ describe('RedisPersistence', () => {
});
});

describe('cleanupExpired', () => {
it('scans room keys instead of blocking with KEYS', async () => {
await redisPersistence.connect();

redisPersistence.client.scan
.mockResolvedValueOnce(['0', ['notesync:room:old-room', 'notesync:room:new-room']]);
redisPersistence.client.hGet
.mockResolvedValueOnce(String(new Date('2020-01-01').getTime()))
.mockResolvedValueOnce(String(new Date('2030-01-01').getTime()));

const deleted = await redisPersistence.cleanupExpired(new Date('2025-01-01'));

expect(deleted).toBe(1);
expect(redisPersistence.client.scan).toHaveBeenCalled();
expect(redisPersistence.client.keys).not.toHaveBeenCalled();
expect(redisPersistence.client.del).toHaveBeenCalledWith('notesync:room:old-room');
expect(redisPersistence.client.del).toHaveBeenCalledWith('notesync:log:old-room');
});
});

describe('getStats', () => {
it('counts room and log keys via SCAN', async () => {
await redisPersistence.connect();

redisPersistence.client.scan
.mockResolvedValueOnce(['0', ['notesync:room:one', 'notesync:room:two']])
.mockResolvedValueOnce(['0', ['notesync:log:one']]);
redisPersistence.client.info.mockResolvedValue('used_memory:42');
redisPersistence.client.dbSize.mockResolvedValue(7);

const stats = await redisPersistence.getStats();

expect(stats.roomCount).toBe(2);
expect(stats.logCount).toBe(1);
expect(redisPersistence.client.scan).toHaveBeenCalledTimes(2);
expect(redisPersistence.client.keys).not.toHaveBeenCalled();
});
});

describe('close', () => {
it('should close connection gracefully', async () => {
await redisPersistence.connect();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/__tests__/useOffline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ vi.mock('../utils/offline', () => ({
}));

vi.mock('../utils/storage', () => ({
getStorageManager: vi.fn().mockReturnValue({
createStorageManager: vi.fn().mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
}),
}));

Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/hooks/__tests__/useStorage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useStorage } from '../useStorage';

// Mock storage manager
vi.mock('../utils/storage', () => ({
getStorageManager: vi.fn().mockReturnValue({
createStorageManager: vi.fn().mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
getStorageType: vi.fn().mockReturnValue('indexeddb'),
saveNotebook: vi.fn().mockResolvedValue(undefined),
Expand All @@ -19,6 +19,7 @@ vi.mock('../utils/storage', () => ({
getHistory: vi.fn().mockResolvedValue([]),
cleanupHistory: vi.fn().mockResolvedValue(undefined),
enqueueOperation: vi.fn().mockResolvedValue('op-id'),
listOperations: vi.fn().mockResolvedValue([]),
dequeueOperations: vi.fn().mockResolvedValue([]),
clearQueue: vi.fn().mockResolvedValue(undefined),
removeOperation: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -70,6 +71,7 @@ describe('useStorage', () => {
const { result } = renderHook(() => useStorage());

expect(typeof result.current.enqueueOperation).toBe('function');
expect(typeof result.current.listOperations).toBe('function');
expect(typeof result.current.dequeueOperations).toBe('function');
expect(typeof result.current.clearQueue).toBe('function');
expect(typeof result.current.removeOperation).toBe('function');
Expand Down
105 changes: 105 additions & 0 deletions apps/web/src/hooks/socket/__tests__/socket-event-binding.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from 'vitest';

const toast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
}));

vi.mock('react-hot-toast', () => ({
default: toast,
}));

import { bindSocketEvents } from '../socket-event-binding';

const createMockSocket = () => {
const handlers = {};
return {
handlers,
on: vi.fn((event, handler) => {
handlers[event] = handler;
}),
emit: vi.fn(),
};
};

describe('socket-event-binding', () => {
it('binds connect handler that joins room and flushes queue', async () => {
const socket = createMockSocket();
const reconnectAttemptRef = { current: 5 };
const isReconnectingRef = { current: false };
const setStatus = vi.fn();
const initOfflineQueue = vi.fn().mockResolvedValue(undefined);
const processQueuedOperations = vi.fn().mockResolvedValue(undefined);

bindSocketEvents({
socket,
keys: { roomId: 'room-1', encryptionKey: 'key-1' },
name: 'Laptop',
t: {
connected: 'connected',
disconnected: 'disconnected',
reconnecting: 'reconnecting',
reconnected: 'reconnected',
syncError: 'sync-error',
},
setStatus,
setMembers: vi.fn(),
initOfflineQueue,
processQueuedOperations,
handleRemoteContent: vi.fn(),
chunkManager: { reassemble: vi.fn() },
reconnectAttemptRef,
isReconnectingRef,
});

await socket.handlers.connect();

expect(setStatus).toHaveBeenCalledWith('connected');
expect(reconnectAttemptRef.current).toBe(0);
expect(socket.emit).toHaveBeenCalledWith('join-chain', {
roomId: 'room-1',
deviceName: 'Laptop',
});
expect(initOfflineQueue).toHaveBeenCalled();
expect(processQueuedOperations).toHaveBeenCalled();
});

it('uses processSyncPayload dependency for sync-update handling', async () => {
const socket = createMockSocket();
const processSyncPayload = vi.fn().mockResolvedValue(undefined);
const payload = { encryptedData: 'ciphertext' };

bindSocketEvents({
socket,
keys: { roomId: 'room-1', encryptionKey: 'key-1' },
name: 'Laptop',
t: {
connected: 'connected',
disconnected: 'disconnected',
reconnecting: 'reconnecting',
reconnected: 'reconnected',
syncError: 'sync-error',
},
setStatus: vi.fn(),
setMembers: vi.fn(),
initOfflineQueue: vi.fn().mockResolvedValue(undefined),
processQueuedOperations: vi.fn().mockResolvedValue(undefined),
handleRemoteContent: vi.fn(),
chunkManager: { reassemble: vi.fn() },
reconnectAttemptRef: { current: 0 },
isReconnectingRef: { current: false },
processSyncPayload,
});

await socket.handlers['sync-update'](payload);

expect(processSyncPayload).toHaveBeenCalledWith({
payload,
encryptionKey: 'key-1',
chunkManager: expect.any(Object),
onRemoteContent: expect.any(Function),
});
});
});
55 changes: 55 additions & 0 deletions apps/web/src/hooks/socket/__tests__/socket-sync-utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest';
import {
emitEncryptedUpdate,
processEncryptedSyncPayload,
} from '../socket-sync-utils';

describe('socket-sync-utils', () => {
it('emitEncryptedUpdate emits encrypted push-update payload(s)', () => {
const socket = { emit: vi.fn() };
const keys = { roomId: 'room-1', encryptionKey: 'key-1' };

const hash = emitEncryptedUpdate({
socket,
keys,
content: 'hello world',
timestamp: 12345,
});

expect(typeof hash).toBe('string');
expect(hash.length).toBeGreaterThan(0);
expect(socket.emit).toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'push-update',
expect.objectContaining({
roomId: 'room-1',
timestamp: 12345,
chunkIndex: 0,
totalChunks: expect.any(Number),
})
);
});

it('processEncryptedSyncPayload resolves plain content payloads', async () => {
const payload = {
encryptedData: 'cipher',
version: 2,
timestamp: 200,
deviceName: 'remote',
};
const onRemoteContent = vi.fn().mockResolvedValue(undefined);
const chunkManager = { reassemble: vi.fn() };
const decrypt = vi.fn().mockReturnValue({ content: 'remote-content' });

await processEncryptedSyncPayload({
payload,
encryptionKey: 'key',
chunkManager,
onRemoteContent,
decrypt,
});

expect(onRemoteContent).toHaveBeenCalledWith('remote-content', payload);
expect(chunkManager.reassemble).not.toHaveBeenCalled();
});
});
Loading
Loading