Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions apps/dispatcher/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SubscriptionClient } from './services/subscription-client.service';
import { NotificationClientFactory } from './services/notification/notification-factory.service';
import { RabbitMQNotificationService } from './services/notification/rabbitmq-notification.service';
import { NewProposalTriggerHandler } from './services/triggers/new-proposal-trigger.service';
import { NewOffchainProposalTriggerHandler } from './services/triggers/new-offchain-proposal-trigger.service';
import { VotingPowerTriggerHandler } from './services/triggers/voting-power-trigger.service';
import { ProposalFinishedTriggerHandler } from './services/triggers/proposal-finished-trigger.service';
import { NonVotingHandler } from './services/triggers/non-voting-handler.service';
Expand Down Expand Up @@ -63,6 +64,11 @@ export class App {
new NewProposalTriggerHandler(subscriptionClient, notificationFactory, anticaptureClient)
);

triggerProcessorService.addHandler(
'new-offchain-proposal',
new NewOffchainProposalTriggerHandler(subscriptionClient, notificationFactory)
);

triggerProcessorService.addHandler(
'voting-power-changed',
new VotingPowerTriggerHandler(subscriptionClient, notificationFactory)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { NewOffchainProposalTriggerHandler } from './new-offchain-proposal-trigger.service';
import { ISubscriptionClient, User, Notification } from '../../interfaces/subscription-client.interface';
import { NotificationClientFactory } from '../notification/notification-factory.service';
import { INotificationClient, NotificationPayload } from '../../interfaces/notification-client.interface';
import { DispatcherMessage } from '../../interfaces/dispatcher-message.interface';

class SimpleSubscriptionClient implements ISubscriptionClient {
subscribers: Map<string, User[]> = new Map();
sentNotifications: Notification[] = [];

async getDaoSubscribers(daoId: string): Promise<User[]> {
return this.subscribers.get(daoId) || [];
}

async shouldSend(subscribers: User[], eventId: string, daoId: string): Promise<Notification[]> {
return subscribers.map(s => ({ user_id: s.id, event_id: eventId, dao_id: daoId }));
}

async shouldSendBatch(): Promise<Notification[][]> { return []; }

async markAsSent(notifications: Notification[]): Promise<void> {
this.sentNotifications.push(...notifications);
}

async getWalletOwners(): Promise<User[]> { return []; }
async getWalletOwnersBatch(): Promise<Record<string, User[]>> { return {}; }
async getFollowedAddresses(): Promise<string[]> { return []; }
}

class SimpleNotificationClient implements INotificationClient {
sentPayloads: NotificationPayload[] = [];

async sendNotification(payload: NotificationPayload): Promise<void> {
this.sentPayloads.push(payload);
}
}

function createHandler(
subscriptionClient: SimpleSubscriptionClient,
notificationClient: SimpleNotificationClient,
) {
const factory = new NotificationClientFactory();
factory.addClient('telegram', notificationClient);
factory.addClient('slack', notificationClient);
return new NewOffchainProposalTriggerHandler(subscriptionClient, factory);
}

describe('NewOffchainProposalTriggerHandler', () => {
let subscriptionClient: SimpleSubscriptionClient;
let notificationClient: SimpleNotificationClient;
let handler: NewOffchainProposalTriggerHandler;

const testUser: User = {
id: 'user-1',
channel: 'telegram',
channel_user_id: '123',
created_at: new Date(),
};

beforeEach(() => {
subscriptionClient = new SimpleSubscriptionClient();
notificationClient = new SimpleNotificationClient();
handler = createHandler(subscriptionClient, notificationClient);
});

describe('handleMessage', () => {
it('should produce correct message text for single proposal', async () => {
subscriptionClient.subscribers.set('test-dao', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [{
daoId: 'test-dao', id: 'snap-1', title: 'Grant Program',
created: 1700000000, discussion: '', link: 'https://snapshot.org/#/test-dao/proposal/snap-1', state: 'active',
}],
};

await handler.handleMessage(message);

expect(notificationClient.sentPayloads).toHaveLength(1);
expect(notificationClient.sentPayloads[0].message).toContain("Grant Program");
});

it('should use "Untitled Proposal" when title is empty', async () => {
subscriptionClient.subscribers.set('test-dao', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [{
daoId: 'test-dao', id: 'snap-1', title: '',
created: 1700000000, discussion: '', link: 'https://snapshot.org/#/test-dao/proposal/snap-1', state: 'active',
}],
};

await handler.handleMessage(message);

expect(notificationClient.sentPayloads[0].message).toContain('Untitled Proposal');
});

it('should set correct eventId as offchain-{proposalId}', async () => {
subscriptionClient.subscribers.set('test-dao', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [{
daoId: 'test-dao', id: 'snap-123', title: 'Test',
created: 1700000000, discussion: '', link: 'https://snapshot.org/#/test-dao/proposal/snap-123', state: 'active',
}],
};

await handler.handleMessage(message);

expect(subscriptionClient.sentNotifications[0].event_id).toBe('offchain-snap-123');
});

it('should always include CTA button "Cast your vote"', async () => {
subscriptionClient.subscribers.set('test-dao', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [{
daoId: 'test-dao', id: 'snap-1', title: 'Test',
created: 1700000000, discussion: '', link: 'https://snapshot.org/#/test-dao/proposal/snap-1', state: 'active',
}],
};

await handler.handleMessage(message);

const buttons = notificationClient.sentPayloads[0].metadata?.buttons;
expect(buttons).toBeDefined();
expect(buttons[0].text).toBe('Cast your vote');
expect(buttons[0].url).toBe('https://snapshot.org/#/test-dao/proposal/snap-1');
});

it('should include "View Discussion" button when discussion URL is provided', async () => {
subscriptionClient.subscribers.set('test-dao', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [{
daoId: 'test-dao', id: 'snap-1', title: 'Test',
created: 1700000000, discussion: 'https://forum.example.com/123', link: 'https://snapshot.org/#/test-dao/proposal/snap-1', state: 'active',
}],
};

await handler.handleMessage(message);

const buttons = notificationClient.sentPayloads[0].metadata?.buttons;
expect(buttons).toHaveLength(2);
expect(buttons[1].text).toBe('View Discussion');
expect(buttons[1].url).toBe('https://forum.example.com/123');
});

it('should omit "View Discussion" button when discussion is empty', async () => {
subscriptionClient.subscribers.set('test-dao', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [{
daoId: 'test-dao', id: 'snap-1', title: 'Test',
created: 1700000000, discussion: '', link: 'https://snapshot.org/#/test-dao/proposal/snap-1', state: 'active',
}],
};

await handler.handleMessage(message);

const buttons = notificationClient.sentPayloads[0].metadata?.buttons;
expect(buttons).toHaveLength(1);
});

it('should process multiple proposals and notify all subscribers', async () => {
const user2: User = { id: 'user-2', channel: 'telegram', channel_user_id: '456', created_at: new Date() };
subscriptionClient.subscribers.set('dao-a', [testUser, user2]);
subscriptionClient.subscribers.set('dao-b', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [
{ daoId: 'dao-a', id: 'snap-1', title: 'Proposal A', created: 1700000000, discussion: '', link: 'https://snapshot.org/#/dao-a/proposal/snap-1', state: 'active' },
{ daoId: 'dao-b', id: 'snap-2', title: 'Proposal B', created: 1700000100, discussion: '', link: 'https://snapshot.org/#/dao-b/proposal/snap-2', state: 'active' },
],
};

await handler.handleMessage(message);

expect(notificationClient.sentPayloads).toHaveLength(3);
});

it('should not perform lookups when events array is empty', async () => {
subscriptionClient.subscribers.set('test-dao', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [],
};

await handler.handleMessage(message);

expect(notificationClient.sentPayloads).toHaveLength(0);
expect(subscriptionClient.sentNotifications).toHaveLength(0);
});

it('should mark notifications as sent after successful delivery', async () => {
subscriptionClient.subscribers.set('test-dao', [testUser]);

const message: DispatcherMessage = {
triggerId: 'new-offchain-proposal',
events: [{
daoId: 'test-dao', id: 'snap-1', title: 'Test',
created: 1700000000, discussion: '', link: 'https://snapshot.org/#/test-dao/proposal/snap-1', state: 'active',
}],
};

await handler.handleMessage(message);

expect(subscriptionClient.sentNotifications).toHaveLength(1);
expect(subscriptionClient.sentNotifications[0]).toEqual({
user_id: 'user-1',
event_id: 'offchain-snap-1',
dao_id: 'test-dao',
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { DispatcherMessage, MessageProcessingResult } from "../../interfaces/dispatcher-message.interface";
import { ISubscriptionClient } from "../../interfaces/subscription-client.interface";
import { NotificationClientFactory } from "../notification/notification-factory.service";
import { BaseTriggerHandler } from "./base-trigger.service";
import { newOffchainProposalMessages, replacePlaceholders, buildButtons } from '@notification-system/messages';
import crypto from 'crypto';

/**
* Handler for processing "new-offchain-proposal" trigger messages (Snapshot proposals)
*/
export class NewOffchainProposalTriggerHandler extends BaseTriggerHandler {
constructor(
subscriptionClient: ISubscriptionClient,
notificationFactory: NotificationClientFactory,
) {
super(subscriptionClient, notificationFactory);
}

/**
* Handle a new offchain proposal message
* @param message The message containing offchain proposal data
*/
async handleMessage(message: DispatcherMessage): Promise<MessageProcessingResult> {
for (const proposal of message.events) {
const { daoId, id: proposalId, title, created, discussion, link } = proposal;
const eventId = `offchain-${proposalId}`;
const proposalTimestamp = String(created);

const subscribers = await this.getSubscribers(daoId, eventId, proposalTimestamp);
const notificationMessage = replacePlaceholders(newOffchainProposalMessages.notification, {
daoId,
title: title || 'Untitled Proposal'
});

// Build buttons — include discussion link if available, no txHash for offchain
const buttons = buildButtons({
triggerType: 'newOffchainProposal',
proposalUrl: link || undefined,
discussionUrl: discussion || undefined,
});

await this.sendNotificationsToSubscribers(
subscribers,
notificationMessage,
eventId,
daoId,
undefined,
buttons
);
}

return {
messageId: crypto.randomUUID(),
timestamp: new Date().toISOString()
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @notice Data structure for offchain (Snapshot) proposals in integration tests
* @dev Matches the shape returned by AnticaptureClient.listOffchainProposals
*/
export interface OffchainProposalData {
id: string;
daoId: string;
title: string;
discussion: string;
state: string;
created: number;
}

/**
* @notice Factory class for creating test offchain proposal data
* @dev Provides methods to generate Snapshot-style proposal objects for integration testing
*/
export class OffchainProposalFactory {
/**
* @notice Creates a single offchain proposal with default or custom data
* @param daoId The DAO ID this proposal belongs to
* @param proposalId Unique identifier for the proposal
* @param overrides Optional partial data to override defaults
* @return Complete OffchainProposalData object ready for testing
*/
static createProposal(
daoId: string,
proposalId: string,
overrides?: Partial<OffchainProposalData>,
): OffchainProposalData {
const futureTimestamp = Math.floor(Date.now() / 1000) + 5;

return {
id: proposalId,
daoId,
title: `Test Snapshot Proposal ${proposalId}`,
discussion: '',
state: 'active',
created: futureTimestamp,
...overrides,
};
}

/**
* @notice Creates multiple offchain proposals for the same DAO
* @param daoId The DAO ID all proposals belong to
* @param count Number of proposals to create
* @param baseId Base string for proposal IDs (will append -1, -2, etc.)
* @return Array of OffchainProposalData objects with sequential IDs
*/
static createMultipleProposals(
daoId: string,
count: number,
baseId: string = 'snap-proposal',
): OffchainProposalData[] {
const baseTime = Math.floor(Date.now() / 1000) + 100;
return Array.from({ length: count }, (_, index) =>
this.createProposal(daoId, `${baseId}-${index + 1}`, {
created: baseTime + index * 10,
}),
);
}
}
3 changes: 2 additions & 1 deletion apps/integrated-tests/src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export * from './factories/user-factory';
export * from './factories/proposal-factory';
export * from './factories/voting-power-factory';
export * from './factories/vote-factory';
export * from './factories/workspace-factory';
export * from './factories/workspace-factory';
export * from './factories/offchain-proposal-factory';
Loading