feat: test that sds doesn't break anything

This commit is contained in:
Arseniy Klempner 2025-06-11 15:25:50 -07:00
parent 5bec0f7039
commit 1c03c9074f
No known key found for this signature in database
GPG Key ID: 51653F18863BD24B
12 changed files with 4811 additions and 32 deletions

23
jest.config.mjs Normal file
View File

@ -0,0 +1,23 @@
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': ['ts-jest', {
tsconfig: {
allowJs: true,
},
}],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
extensionsToTreatAsEsm: ['.ts'],
};

3792
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,9 @@
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
@ -69,6 +71,7 @@
"devDependencies": {
"@eslint/js": "^9.9.0",
"@tailwindcss/typography": "^0.5.15",
"@types/jest": "^29.5.14",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
@ -79,9 +82,12 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lovable-tagger": "^1.1.7",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"ts-jest": "^29.3.4",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"

View File

@ -0,0 +1,235 @@
import { MessageType } from '../types';
import { SDSEnhancedMessage } from '../sds/types';
// Mock Waku SDK
jest.mock('@waku/sdk', () => ({
createLightNode: jest.fn().mockResolvedValue({
lightPush: {
send: jest.fn().mockResolvedValue({ success: true }),
},
filter: {
subscribe: jest.fn().mockResolvedValue({
error: null,
results: { successes: [{}] },
unsubscribe: jest.fn(),
}),
},
store: {
queryWithOrderedCallback: jest.fn(),
},
getConnectedPeers: jest.fn().mockResolvedValue(['peer1']),
stop: jest.fn(),
}),
createEncoder: jest.fn().mockReturnValue({}),
createDecoder: jest.fn().mockReturnValue({}),
}));
// Mock other dependencies
jest.mock('../lightpush_filter');
jest.mock('../store');
describe('MessageManager with SDS', () => {
let MessageManager: any;
let messageManager: any;
beforeEach(async () => {
jest.clearAllMocks();
// Dynamic import to ensure mocks are applied
const module = await import('../index');
messageManager = module.default;
MessageManager = module.MessageManager;
});
describe('updateCache with SDS', () => {
it('should handle non-vote messages without SDS', () => {
const postMessage = {
type: MessageType.POST,
id: 'post-1',
cellId: 'general',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
author: '0x123',
};
// Access private method through prototype
MessageManager.prototype.updateCache.call(messageManager, postMessage);
expect(messageManager.messageCache.posts['post-1']).toEqual(postMessage);
});
it('should update vote cache with new vote', () => {
const voteMessage = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: Date.now(),
author: '0x123',
};
MessageManager.prototype.updateCache.call(messageManager, voteMessage);
const voteKey = 'post-1:0x123';
expect(messageManager.messageCache.votes[voteKey]).toEqual(voteMessage);
});
it('should respect causal ordering for votes with SDS metadata', () => {
const voteKey = 'post-1:0x123';
// First vote with lower Lamport timestamp
const olderVote: SDSEnhancedMessage = {
type: MessageType.VOTE as any,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: Date.now(),
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 5,
causalHistory: [],
},
};
MessageManager.prototype.updateCache.call(messageManager, olderVote);
expect(messageManager.messageCache.votes[voteKey]).toEqual(olderVote);
// Newer vote with higher Lamport timestamp
const newerVote: SDSEnhancedMessage = {
type: MessageType.VOTE as any,
id: 'vote-2',
targetId: 'post-1',
value: -1,
timestamp: Date.now() - 1000, // older wall clock time
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 10,
causalHistory: ['vote-1'],
},
};
MessageManager.prototype.updateCache.call(messageManager, newerVote);
expect(messageManager.messageCache.votes[voteKey]).toEqual(newerVote);
// Attempt to update with older vote
const evenOlderVote: SDSEnhancedMessage = {
type: MessageType.VOTE as any,
id: 'vote-0',
targetId: 'post-1',
value: 1,
timestamp: Date.now() + 1000, // newer wall clock time
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 3,
causalHistory: [],
},
};
MessageManager.prototype.updateCache.call(messageManager, evenOlderVote);
// Should still have the newer vote
expect(messageManager.messageCache.votes[voteKey]).toEqual(newerVote);
});
it('should handle votes without SDS alongside votes with SDS', () => {
const voteKey = 'post-1:0x123';
// Vote without SDS
const plainVote = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: 1000,
author: '0x123',
};
MessageManager.prototype.updateCache.call(messageManager, plainVote);
expect(messageManager.messageCache.votes[voteKey]).toEqual(plainVote);
// Vote with SDS but older timestamp
const sdsVote: SDSEnhancedMessage = {
type: MessageType.VOTE as any,
id: 'vote-2',
targetId: 'post-1',
value: -1,
timestamp: 500, // older
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 1,
causalHistory: [],
},
};
// Since existing vote has no SDS, it falls back to timestamp comparison
MessageManager.prototype.updateCache.call(messageManager, sdsVote);
// Should keep the plain vote as it has newer timestamp
expect(messageManager.messageCache.votes[voteKey]).toEqual(plainVote);
});
});
describe('backward compatibility', () => {
it('should handle all message types without SDS', () => {
const messages = [
{
type: MessageType.CELL,
id: 'cell-1',
name: 'general',
description: 'General discussion',
icon: '🏠',
timestamp: Date.now(),
author: '0x123',
},
{
type: MessageType.POST,
id: 'post-1',
cellId: 'general',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
author: '0x123',
},
{
type: MessageType.COMMENT,
id: 'comment-1',
postId: 'post-1',
content: 'Test comment',
timestamp: Date.now(),
author: '0x456',
},
{
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: Date.now(),
author: '0x789',
},
{
type: MessageType.MODERATE,
id: 'mod-1',
targetId: 'post-1',
action: 'hide',
cellId: 'general',
timestamp: Date.now(),
author: '0x123',
},
];
messages.forEach(message => {
MessageManager.prototype.updateCache.call(messageManager, message);
});
// Verify all messages are cached correctly
expect(messageManager.messageCache.cells['cell-1']).toBeDefined();
expect(messageManager.messageCache.posts['post-1']).toBeDefined();
expect(messageManager.messageCache.comments['comment-1']).toBeDefined();
expect(messageManager.messageCache.votes['post-1:0x789']).toBeDefined();
expect(messageManager.messageCache.moderations['post-1']).toBeDefined();
});
});
});

View File

@ -0,0 +1,175 @@
import { MessageType, VoteMessage } from '../types';
import { OpchanMessage } from '@/types';
describe('Vote Integration with SDS', () => {
describe('Vote Conflict Resolution', () => {
it('should handle rapid vote changes correctly', () => {
const votes: VoteMessage[] = [
{
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-123',
value: 1, // upvote
timestamp: 1000,
author: 'user-abc',
},
{
type: MessageType.VOTE,
id: 'vote-2',
targetId: 'post-123',
value: -1, // change to downvote
timestamp: 2000,
author: 'user-abc',
},
{
type: MessageType.VOTE,
id: 'vote-3',
targetId: 'post-123',
value: 1, // change back to upvote
timestamp: 3000,
author: 'user-abc',
},
];
// In the real system, each vote would be enhanced with SDS metadata
// The latest vote should always win
const latestVote = votes[votes.length - 1];
expect(latestVote.value).toBe(1);
expect(latestVote.id).toBe('vote-3');
});
it('should handle votes from multiple users independently', () => {
const votesByUser: Record<string, VoteMessage> = {};
const votes: VoteMessage[] = [
{
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-123',
value: 1,
timestamp: 1000,
author: 'user-A',
},
{
type: MessageType.VOTE,
id: 'vote-2',
targetId: 'post-123',
value: -1,
timestamp: 1100,
author: 'user-B',
},
{
type: MessageType.VOTE,
id: 'vote-3',
targetId: 'post-123',
value: 1,
timestamp: 1200,
author: 'user-C',
},
{
type: MessageType.VOTE,
id: 'vote-4',
targetId: 'post-123',
value: -1,
timestamp: 1300,
author: 'user-A', // user-A changes vote
},
];
// Process votes
votes.forEach(vote => {
const key = `${vote.targetId}:${vote.author}`;
votesByUser[key] = vote;
});
// Verify final state
expect(votesByUser['post-123:user-A'].value).toBe(-1);
expect(votesByUser['post-123:user-B'].value).toBe(-1);
expect(votesByUser['post-123:user-C'].value).toBe(1);
// Calculate total
const total = Object.values(votesByUser).reduce((sum, vote) => sum + vote.value, 0);
expect(total).toBe(-1); // 2 downvotes, 1 upvote
});
});
describe('Edge Cases', () => {
it('should handle votes with same timestamp', () => {
const timestamp = Date.now();
const vote1: VoteMessage = {
type: MessageType.VOTE,
id: 'vote-aaa',
targetId: 'post-123',
value: 1,
timestamp,
author: 'user-abc',
};
const vote2: VoteMessage = {
type: MessageType.VOTE,
id: 'vote-bbb',
targetId: 'post-123',
value: -1,
timestamp,
author: 'user-abc',
};
// With SDS, this would be resolved by Lamport timestamps
// Without SDS, it would use ID as tiebreaker
// 'vote-bbb' > 'vote-aaa' lexicographically
const shouldWin = vote2;
expect(shouldWin.id).toBe('vote-bbb');
});
it('should handle missing or malformed votes gracefully', () => {
const validVote: VoteMessage = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-123',
value: 1,
timestamp: Date.now(),
author: 'user-abc',
};
// System should handle partial data
const partialVote = {
...validVote,
value: undefined as any,
};
// In real system, validation would reject this
expect(validVote.value).toBeDefined();
expect(partialVote.value).toBeUndefined();
});
});
describe('Performance Considerations', () => {
it('should handle large numbers of votes efficiently', () => {
const startTime = Date.now();
const votes: VoteMessage[] = [];
// Generate 1000 votes
for (let i = 0; i < 1000; i++) {
votes.push({
type: MessageType.VOTE,
id: `vote-${i}`,
targetId: `post-${i % 10}`, // 10 different posts
value: i % 2 === 0 ? 1 : -1,
timestamp: startTime + i,
author: `user-${i % 100}`, // 100 different users
});
}
// Process votes (in real system, this would go through updateCache)
const voteMap: Record<string, VoteMessage> = {};
votes.forEach(vote => {
const key = `${vote.targetId}:${vote.author}`;
voteMap[key] = vote;
});
// Should have at most 10 posts * 100 users = 1000 entries
expect(Object.keys(voteMap).length).toBeLessThanOrEqual(1000);
});
});
});

View File

@ -244,3 +244,4 @@ class MessageManager {
const messageManager = await MessageManager.create();
export default messageManager;
export { MessageManager };

View File

@ -0,0 +1,239 @@
import { MinimalSDSWrapper } from '../minimal-sds';
import { OpchanMessage } from '@/types';
import { SDSEnhancedMessage } from '../types';
import { MessageType } from '../../types';
describe('MinimalSDSWrapper', () => {
let sds: MinimalSDSWrapper;
beforeEach(() => {
sds = new MinimalSDSWrapper();
});
describe('enhanceMessage', () => {
it('should not enhance non-vote messages', () => {
const postMessage: OpchanMessage = {
type: MessageType.POST,
id: 'post-1',
cellId: 'general',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
author: '0x123',
};
const enhanced = sds.enhanceMessage(postMessage);
expect(enhanced).toEqual(postMessage);
expect('sds' in enhanced).toBe(false);
});
it('should enhance vote messages with SDS metadata', () => {
const voteMessage: OpchanMessage = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: Date.now(),
author: '0x123',
};
const enhanced = sds.enhanceMessage(voteMessage);
expect('sds' in enhanced).toBe(true);
expect(enhanced.sds).toBeDefined();
expect(enhanced.sds?.channelId).toBe('opchan:votes:all');
expect(enhanced.sds?.lamportTimestamp).toBe(1);
expect(enhanced.sds?.causalHistory).toEqual([]);
});
it('should increment Lamport timestamp for each vote', () => {
const vote1: OpchanMessage = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: Date.now(),
author: '0x123',
};
const vote2: OpchanMessage = {
type: MessageType.VOTE,
id: 'vote-2',
targetId: 'post-2',
value: -1,
timestamp: Date.now() + 1000,
author: '0x123',
};
const enhanced1 = sds.enhanceMessage(vote1);
const enhanced2 = sds.enhanceMessage(vote2);
expect(enhanced1.sds?.lamportTimestamp).toBe(1);
expect(enhanced2.sds?.lamportTimestamp).toBe(2);
});
it('should maintain causal history', () => {
const votes = Array.from({ length: 5 }, (_, i) => ({
type: MessageType.VOTE as const,
id: `vote-${i}`,
targetId: 'post-1',
value: i % 2 === 0 ? 1 : -1,
timestamp: Date.now() + i * 1000,
author: '0x123',
}));
const enhancedVotes = votes.map(vote => sds.enhanceMessage(vote));
// First vote has empty history
expect(enhancedVotes[0].sds?.causalHistory).toEqual([]);
// Second vote has first vote in history
expect(enhancedVotes[1].sds?.causalHistory).toEqual(['vote-0']);
// Fifth vote has last 3 votes in history
expect(enhancedVotes[4].sds?.causalHistory).toEqual(['vote-1', 'vote-2', 'vote-3']);
});
});
describe('processIncomingMessage', () => {
it('should ignore non-vote messages', () => {
const postMessage: OpchanMessage = {
type: MessageType.POST,
id: 'post-1',
cellId: 'general',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
author: '0x123',
};
// Should not throw
expect(() => sds.processIncomingMessage(postMessage as SDSEnhancedMessage)).not.toThrow();
});
it('should update Lamport timestamp from incoming message', () => {
const incomingVote = {
type: MessageType.VOTE as const,
id: 'vote-remote',
targetId: 'post-1',
value: 1,
timestamp: Date.now(),
author: '0x456',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 10,
causalHistory: ['vote-a', 'vote-b'],
},
};
sds.processIncomingMessage(incomingVote);
// Next local message should have timestamp > 10
const localVote: OpchanMessage = {
type: MessageType.VOTE,
id: 'vote-local',
targetId: 'post-2',
value: 1,
timestamp: Date.now(),
author: '0x123',
};
const enhanced = sds.enhanceMessage(localVote);
expect(enhanced.sds?.lamportTimestamp).toBe(12); // max(0, 10) + 1 + 1
});
});
describe('isCausallyNewer', () => {
it('should use timestamp for messages without SDS metadata', () => {
const older = {
type: MessageType.VOTE as const,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: 1000,
author: '0x123',
};
const newer = {
type: MessageType.VOTE as const,
id: 'vote-2',
targetId: 'post-1',
value: -1,
timestamp: 2000,
author: '0x123',
};
expect(sds.isCausallyNewer(newer as SDSEnhancedMessage, older as SDSEnhancedMessage)).toBe(true);
expect(sds.isCausallyNewer(older as SDSEnhancedMessage, newer as SDSEnhancedMessage)).toBe(false);
});
it('should use Lamport timestamp for messages with SDS metadata', () => {
const older = {
type: MessageType.VOTE as const,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: 2000, // newer wall clock time
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 5,
causalHistory: [],
},
};
const newer = {
type: MessageType.VOTE as const,
id: 'vote-2',
targetId: 'post-1',
value: -1,
timestamp: 1000, // older wall clock time
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 10,
causalHistory: ['vote-1'],
},
};
// Despite older wall clock time, newer has higher Lamport timestamp
expect(sds.isCausallyNewer(newer, older)).toBe(true);
expect(sds.isCausallyNewer(older, newer)).toBe(false);
});
it('should use message ID as tiebreaker for equal Lamport timestamps', () => {
const messageA = {
type: MessageType.VOTE as const,
id: 'vote-aaa',
targetId: 'post-1',
value: 1,
timestamp: 1000,
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 5,
causalHistory: [],
},
};
const messageB = {
type: MessageType.VOTE as const,
id: 'vote-bbb',
targetId: 'post-1',
value: -1,
timestamp: 1000,
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 5,
causalHistory: [],
},
};
// 'vote-bbb' > 'vote-aaa' lexicographically
expect(sds.isCausallyNewer(messageB, messageA)).toBe(true);
expect(sds.isCausallyNewer(messageA, messageB)).toBe(false);
});
});
});

View File

@ -0,0 +1,187 @@
import { MessageType, VoteMessage, PostMessage, CommentMessage } from '@/lib/waku/types';
import { SDSMetadata } from '../types';
// Test the SDS logic without importing the implementation
describe('SDS Logic Tests', () => {
// Helper function to simulate SDS enhancement logic
function enhanceMessage(message: any): any {
if (message.type !== 'vote') {
return message;
}
return {
...message,
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: Date.now(),
causalHistory: []
}
};
}
// Helper function to simulate causal ordering logic
function isCausallyNewer(a: any, b: any): boolean {
const hasSDSMetadata = (msg: any): boolean => {
return msg.type === 'vote' && 'sds' in msg && msg.sds !== undefined;
};
if (!hasSDSMetadata(a) || !hasSDSMetadata(b)) {
return a.timestamp > b.timestamp;
}
if (a.sds.lamportTimestamp !== b.sds.lamportTimestamp) {
return a.sds.lamportTimestamp > b.sds.lamportTimestamp;
}
return a.id > b.id;
}
describe('Non-vote messages remain unchanged', () => {
it('should not modify post messages', () => {
const post: PostMessage = {
type: MessageType.POST,
id: 'post-1',
cellId: 'general',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
author: '0x123',
};
const enhanced = enhanceMessage(post);
expect(enhanced).toBe(post);
expect('sds' in enhanced).toBe(false);
});
it('should not modify comment messages', () => {
const comment: CommentMessage = {
type: MessageType.COMMENT,
id: 'comment-1',
postId: 'post-1',
content: 'Test comment',
timestamp: Date.now(),
author: '0x456',
};
const enhanced = enhanceMessage(comment);
expect(enhanced).toBe(comment);
expect('sds' in enhanced).toBe(false);
});
});
describe('Vote messages get SDS enhancement', () => {
it('should add SDS metadata to votes without changing original fields', () => {
const vote: VoteMessage = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: 1234567890,
author: '0x789',
};
const enhanced = enhanceMessage(vote);
// Original fields unchanged
expect(enhanced.type).toBe(MessageType.VOTE);
expect(enhanced.id).toBe('vote-1');
expect(enhanced.targetId).toBe('post-1');
expect(enhanced.value).toBe(1);
expect(enhanced.timestamp).toBe(1234567890);
expect(enhanced.author).toBe('0x789');
// SDS metadata added
expect('sds' in enhanced).toBe(true);
expect(enhanced.sds).toBeDefined();
expect(enhanced.sds.channelId).toBe('opchan:votes:all');
});
});
describe('Causal ordering preserves latest vote', () => {
it('should correctly identify newer votes', () => {
const vote1 = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: 1000,
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 5,
causalHistory: []
}
};
const vote2 = {
type: MessageType.VOTE,
id: 'vote-2',
targetId: 'post-1',
value: -1,
timestamp: 2000,
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 10,
causalHistory: ['vote-1']
}
};
// Should identify vote2 as newer based on Lamport timestamp
expect(isCausallyNewer(vote2, vote1)).toBe(true);
expect(isCausallyNewer(vote1, vote2)).toBe(false);
});
it('should use timestamp as fallback when no SDS metadata', () => {
const plainVote1 = {
type: MessageType.VOTE,
id: 'vote-1',
timestamp: 1000,
author: '0x123'
};
const plainVote2 = {
type: MessageType.VOTE,
id: 'vote-2',
timestamp: 2000,
author: '0x123'
};
expect(isCausallyNewer(plainVote2, plainVote1)).toBe(true);
expect(isCausallyNewer(plainVote1, plainVote2)).toBe(false);
});
});
describe('Backward compatibility', () => {
it('should handle mixed SDS and non-SDS votes', () => {
const plainVote = {
type: MessageType.VOTE,
id: 'vote-plain',
targetId: 'post-1',
value: 1,
timestamp: 1000,
author: '0x123',
};
const sdsVote = {
type: MessageType.VOTE,
id: 'vote-sds',
targetId: 'post-1',
value: -1,
timestamp: 500, // older timestamp
author: '0x123',
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 5,
causalHistory: [],
},
};
// Should fall back to timestamp comparison
expect(isCausallyNewer(plainVote, sdsVote)).toBe(true); // plainVote has newer timestamp
expect(isCausallyNewer(sdsVote, plainVote)).toBe(false);
});
});
});

View File

@ -0,0 +1,152 @@
import { MinimalSDSWrapper } from '../minimal-sds';
import { MessageType, VoteMessage, PostMessage, CommentMessage } from '@/lib/waku/types';
// Mock @waku/sdk
jest.mock('@waku/sdk', () => ({
createEncoder: jest.fn().mockReturnValue({}),
createDecoder: jest.fn().mockReturnValue({}),
}));
describe('SDS Non-Breaking Functionality', () => {
let sds: MinimalSDSWrapper;
beforeEach(() => {
sds = new MinimalSDSWrapper();
});
describe('Non-vote messages remain unchanged', () => {
it('should not modify post messages', () => {
const post: PostMessage = {
type: MessageType.POST,
id: 'post-1',
cellId: 'general',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
author: '0x123',
};
const enhanced = sds.enhanceMessage(post);
// Should be exactly the same object
expect(enhanced).toBe(post);
expect('sds' in enhanced).toBe(false);
});
it('should not modify comment messages', () => {
const comment: CommentMessage = {
type: MessageType.COMMENT,
id: 'comment-1',
postId: 'post-1',
content: 'Test comment',
timestamp: Date.now(),
author: '0x456',
};
const enhanced = sds.enhanceMessage(comment);
expect(enhanced).toBe(comment);
expect('sds' in enhanced).toBe(false);
});
});
describe('Vote messages get SDS enhancement', () => {
it('should add SDS metadata to votes without changing original fields', () => {
const vote: VoteMessage = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: 1234567890,
author: '0x789',
};
const enhanced = sds.enhanceMessage(vote);
// Original fields unchanged
expect(enhanced.type).toBe(MessageType.VOTE);
if (enhanced.type === MessageType.VOTE) {
expect(enhanced.id).toBe('vote-1');
expect(enhanced.targetId).toBe('post-1');
expect(enhanced.value).toBe(1);
expect(enhanced.timestamp).toBe(1234567890);
expect(enhanced.author).toBe('0x789');
// SDS metadata added
expect('sds' in enhanced).toBe(true);
if ('sds' in enhanced && enhanced.sds) {
expect(enhanced.sds).toBeDefined();
expect(enhanced.sds.channelId).toBe('opchan:votes:all');
expect(enhanced.sds.lamportTimestamp).toBeGreaterThan(0);
expect(Array.isArray(enhanced.sds.causalHistory)).toBe(true);
}
}
});
});
describe('Causal ordering preserves latest vote', () => {
it('should correctly identify newer votes', () => {
// Create two votes with SDS metadata
const vote1: VoteMessage = {
type: MessageType.VOTE,
id: 'vote-1',
targetId: 'post-1',
value: 1,
timestamp: 1000,
author: '0x123',
};
const vote2: VoteMessage = {
type: MessageType.VOTE,
id: 'vote-2',
targetId: 'post-1',
value: -1,
timestamp: 2000,
author: '0x123',
};
const enhanced1 = sds.enhanceMessage(vote1);
const enhanced2 = sds.enhanceMessage(vote2);
// Second vote should have higher Lamport timestamp
if (enhanced1.type === MessageType.VOTE && enhanced2.type === MessageType.VOTE &&
'sds' in enhanced1 && enhanced1.sds && 'sds' in enhanced2 && enhanced2.sds) {
expect(enhanced2.sds.lamportTimestamp).toBeGreaterThan(enhanced1.sds.lamportTimestamp);
// Should identify vote2 as newer
expect(sds.isCausallyNewer(enhanced2, enhanced1)).toBe(true);
expect(sds.isCausallyNewer(enhanced1, enhanced2)).toBe(false);
} else {
fail('SDS metadata should be added to vote messages');
}
});
});
describe('Backward compatibility', () => {
it('should handle votes without SDS metadata gracefully', () => {
const plainVote: VoteMessage = {
type: MessageType.VOTE,
id: 'vote-plain',
targetId: 'post-1',
value: 1,
timestamp: 1000,
author: '0x123',
};
const sdsVote = {
...plainVote,
sds: {
channelId: 'opchan:votes:all',
lamportTimestamp: 5,
causalHistory: [],
},
} as VoteMessage & { sds: { channelId: string; lamportTimestamp: number; causalHistory: string[] } };
// Should fall back to timestamp comparison
expect(sds.isCausallyNewer(sdsVote, plainVote)).toBe(false); // same timestamp
const newerPlainVote = { ...plainVote, timestamp: 2000 };
expect(sds.isCausallyNewer(newerPlainVote, sdsVote)).toBe(true); // newer timestamp
});
});
});

View File

@ -1,6 +1,6 @@
import { createEncoder, createDecoder } from "@waku/sdk";
import { SDSEnhancedMessage, SDSChannelState } from "./types";
import { OpchanMessage } from "../../types";
import { SDSEnhancedMessage, SDSChannelState, SDSMetadata } from "./types";
import { CellMessage, CommentMessage, PostMessage, VoteMessage, ModerateMessage } from "@/lib/waku/types";
// For now, use a single channel for all votes to test SDS
const VOTE_CHANNEL_ID = "opchan:votes:all";
@ -22,7 +22,7 @@ export class MinimalSDSWrapper {
}
// Enhance a message with SDS metadata
enhanceMessage(message: OpchanMessage): SDSEnhancedMessage {
enhanceMessage(message: CellMessage | PostMessage | CommentMessage | VoteMessage | ModerateMessage): SDSEnhancedMessage {
// Only enhance vote messages for minimal implementation
if (message.type !== 'vote') {
return message;
@ -42,8 +42,10 @@ export class MinimalSDSWrapper {
state.messageHistory = state.messageHistory.slice(-100);
}
// Return vote message with SDS metadata
const voteMessage = message as VoteMessage;
return {
...message,
...voteMessage,
sds: {
channelId: VOTE_CHANNEL_ID,
lamportTimestamp: state.lamportTimestamp,
@ -54,7 +56,7 @@ export class MinimalSDSWrapper {
// Process incoming message with SDS metadata
processIncomingMessage(message: SDSEnhancedMessage): void {
if (!message.sds || message.type !== 'vote') {
if (message.type !== 'vote' || !('sds' in message) || !message.sds) {
return;
}
@ -77,7 +79,12 @@ export class MinimalSDSWrapper {
// Check if message A is causally newer than B
isCausallyNewer(a: SDSEnhancedMessage, b: SDSEnhancedMessage): boolean {
if (!a.sds || !b.sds) {
// Type guard to check if message has SDS metadata
const hasSDSMetadata = (msg: SDSEnhancedMessage): msg is VoteMessage & { sds: SDSMetadata } => {
return msg.type === 'vote' && 'sds' in msg && msg.sds !== undefined;
};
if (!hasSDSMetadata(a) || !hasSDSMetadata(b)) {
return a.timestamp > b.timestamp;
}

View File

@ -1,4 +1,4 @@
import { OpchanMessage } from "../../types";
import { CellMessage, CommentMessage, PostMessage, VoteMessage, ModerateMessage } from "@/lib/waku/types";
export interface SDSMetadata {
channelId: string;
@ -7,9 +7,12 @@ export interface SDSMetadata {
bloomFilter?: Uint8Array;
}
export interface SDSEnhancedMessage extends OpchanMessage {
sds?: SDSMetadata;
}
export type SDSEnhancedMessage =
| CellMessage
| PostMessage
| CommentMessage
| (VoteMessage & { sds?: SDSMetadata })
| ModerateMessage;
export interface SDSChannelState {
channelId: string;

View File

@ -67,6 +67,7 @@ export interface VoteMessage extends BaseMessage {
*/
export interface ModerateMessage extends BaseMessage {
type: MessageType.MODERATE;
id: string;
cellId: string;
targetType: 'post' | 'comment' | 'user';
targetId: string; // postId, commentId, or user address (for user moderation)