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
8 changes: 8 additions & 0 deletions .infra/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,14 @@ export const workers: Worker[] = [
topic: 'api.v1.delayed-notification-reminder',
subscription: 'api.campaign-post-analytics-notification',
},
{
topic: 'api.v1.delayed-notification-reminder',
subscription: 'api.poll-result-author-notification',
},
{
topic: 'api.v1.delayed-notification-reminder',
subscription: 'api.poll-result-notification',
},
];

export const personalizedDigestWorkers: Worker[] = [
Expand Down
155 changes: 155 additions & 0 deletions __tests__/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
GraphQLTestClient,
GraphQLTestingState,
initializeGraphQLTesting,
invokeTypedNotificationWorker,
MockContext,
saveFixtures,
testMutationErrorCode,
Expand All @@ -19,6 +20,8 @@ import {
NotificationPreferenceSource,
NotificationPreference,
NotificationAttachmentType,
PostType,
UserPost,
} from '../src/entity';
import type { UserNotificationFlags } from '../src/entity/user/User';
import { DataSource } from 'typeorm';
Expand All @@ -45,6 +48,11 @@ import {
NotificationAttachmentV2,
NotificationAvatarV2,
} from '../src/entity';
import { getTableName } from '../src/workers/cdc/common';
import { PollPost } from '../src/entity/posts/PollPost';
import { pollResultNotification } from '../src/workers/notifications/pollResultNotification';
import { pollResultAuthorNotification } from '../src/workers/notifications/pollResultAuthorNotification';
import { PollOption } from '../src/entity/polls/PollOption';

let app: FastifyInstance;
let con: DataSource;
Expand Down Expand Up @@ -1465,3 +1473,150 @@ describe('streamNotificationUsers', () => {
expect(results).toHaveLength(0);
});
});

describe('poll result notifications', () => {
beforeEach(async () => {
await con.getRepository(User).save(usersFixture);
await saveFixtures(con, Source, sourcesFixture);
});

const createPollPost = async (authorId: string, endsAt?: Date) => {
return con.getRepository(Post).save({
id: 'poll-1',
shortId: 'poll-short',
authorId,
sourceId: 'a',
title: 'What is your favorite framework?',
type: PostType.Poll,
createdAt: new Date('2021-09-22T07:15:51.247Z'),
endsAt,
});
};

const createPollOptions = async (pollId: string) => {
return con.getRepository(PollOption).save([
{
id: '01234567-0123-0123-0123-0123456789ab',
postId: pollId,
text: 'React',
order: 1,
numVotes: 0,
},
{
id: '01234567-0123-0123-0123-0123456789ac',
postId: pollId,
text: 'Vue',
order: 2,
numVotes: 0,
},
]);
};

const createPollVotes = async (
pollId: string,
voterIds: string[],
optionId = '01234567-0123-0123-0123-0123456789ab',
) => {
return con.getRepository(UserPost).save(
voterIds.map((userId) => ({
userId,
postId: pollId,
pollVoteOptionId: optionId,
})),
);
};

it('should send notification to poll author when poll expires', async () => {
const poll = await createPollPost('1'); // user '1' is the author

const result =
await invokeTypedNotificationWorker<'api.v1.delayed-notification-reminder'>(
pollResultAuthorNotification,
{
entityId: poll.id,
entityTableName: getTableName(con, PollPost),
scheduledAtMs: Date.now(),
delayMs: 1000,
},
);

expect(result).toHaveLength(1);
expect(result![0].type).toBe(NotificationType.PollResult);
expect(result![0].ctx.userIds).toEqual(['1']);
});

it('should send notifications to poll voters when poll expires', async () => {
const poll = await createPollPost('1'); // user '1' is the author
await createPollOptions(poll.id);
await createPollVotes(poll.id, ['2', '3', '4']); // users 2, 3, 4 voted

const result =
await invokeTypedNotificationWorker<'api.v1.delayed-notification-reminder'>(
pollResultNotification,
{
entityId: poll.id,
entityTableName: getTableName(con, PollPost),
scheduledAtMs: Date.now(),
delayMs: 1000,
},
);

expect(result).toHaveLength(1);
expect(result![0].type).toBe(NotificationType.PollResult);
expect(result![0].ctx.userIds).toEqual(['2', '3', '4']);
});

it('should exclude poll author from voter notifications', async () => {
const poll = await createPollPost('1'); // user '1' is the author
await createPollOptions(poll.id);
await createPollVotes(poll.id, ['1', '2', '3']); // author also voted

const result =
await invokeTypedNotificationWorker<'api.v1.delayed-notification-reminder'>(
pollResultNotification,
{
entityId: poll.id,
entityTableName: getTableName(con, PollPost),
scheduledAtMs: Date.now(),
delayMs: 1000,
},
);

expect(result).toHaveLength(1);
expect(result![0].type).toBe(NotificationType.PollResult);
expect(result![0].ctx.userIds).toEqual(['2', '3']); // author excluded
});

it('should return nothing if no voters exist', async () => {
const poll = await createPollPost('1');
// No votes created

const result =
await invokeTypedNotificationWorker<'api.v1.delayed-notification-reminder'>(
pollResultNotification,
{
entityId: poll.id,
entityTableName: getTableName(con, PollPost),
scheduledAtMs: Date.now(),
delayMs: 1000,
},
);

expect(result).toBeUndefined();
});

it('should return nothing if poll does not exist', async () => {
const result =
await invokeTypedNotificationWorker<'api.v1.delayed-notification-reminder'>(
pollResultNotification,
{
entityId: 'non-existent-poll',
entityTableName: getTableName(con, PollPost),
scheduledAtMs: Date.now(),
delayMs: 1000,
},
);

expect(result).toBeUndefined();
});
});
94 changes: 94 additions & 0 deletions __tests__/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
UserTopReader,
WelcomePost,
} from '../../src/entity';
import { PollPost } from '../../src/entity/posts/PollPost';
import { CampaignUpdateEvent } from '../../src/common/campaign/common';
import {
createSquadWelcomePost,
Expand Down Expand Up @@ -1972,3 +1973,96 @@ describe('post analytics notifications', () => {
);
});
});

describe('poll result notifications', () => {
beforeEach(async () => {
jest.resetAllMocks();
});

it('should notify poll result for voters', async () => {
const pollPost: ChangeObject<PollPost> = {
id: 'poll1',
shortId: 'sp1',
title: 'What is your favorite programming language?',
type: PostType.Poll,
sourceId: 'a',
createdAt: 0,
endsAt: null,
};

const type = NotificationType.PollResult;
const ctx: NotificationPostContext = {
userIds: ['1', '2'],
source: sourcesFixture.find(
(item) => item.id === 'a',
) as Reference<Source>,
post: pollPost,
};

const actual = generateNotificationV2(type, ctx);
expect(actual.notification.type).toEqual(type);
expect(actual.userIds).toEqual(['1', '2']);
expect(actual.notification.public).toEqual(true);
expect(actual.notification.referenceId).toEqual(pollPost.id);
expect(actual.notification.targetUrl).toEqual(
'http://localhost:5002/posts/poll1',
);
expect(actual.attachments).toEqual([]);
expect(actual.avatars).toEqual([
{
image: 'http://image.com/a',
name: 'A',
referenceId: 'a',
targetUrl: 'http://localhost:5002/sources/a',
type: 'source',
},
]);
expect(actual.notification.title).toEqual(
'<b>Poll you voted on has ended!</b> See the results for: <b>What is your favorite programming language?</b>',
);
});

it('should notify poll result author', async () => {
const pollPost: ChangeObject<PollPost> = {
id: 'poll1',
shortId: 'sp1',
title: 'What is your favorite programming language?',
type: PostType.Poll,
sourceId: 'a',
createdAt: 0,
endsAt: null,
authorId: '1',
};

const type = NotificationType.PollResultAuthor;
const ctx: NotificationPostContext = {
userIds: ['1'],
source: sourcesFixture.find(
(item) => item.id === 'a',
) as Reference<Source>,
post: pollPost,
};

const actual = generateNotificationV2(type, ctx);
expect(actual.notification.type).toEqual(type);
expect(actual.userIds).toEqual(['1']);
expect(actual.notification.public).toEqual(true);
expect(actual.notification.referenceId).toEqual(pollPost.id);
expect(actual.notification.targetUrl).toEqual(
'http://localhost:5002/posts/poll1',
);
expect(actual.attachments).toEqual([]);
expect(actual.avatars).toEqual([
{
image: 'http://image.com/a',
name: 'A',
referenceId: 'a',
targetUrl: 'http://localhost:5002/sources/a',
type: 'source',
},
]);
expect(actual.notification.title).toEqual(
'<b>Your poll has ended!</b> Check the results for: <b>What is your favorite programming language?</b>',
);
});
});
63 changes: 63 additions & 0 deletions __tests__/workers/cdc/primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6526,3 +6526,66 @@ describe('campaign post', () => {
expect(cancelEntityReminderWorkflow).toHaveBeenCalledTimes(0);
});
});

describe('poll post', () => {
it('should schedule entity reminder workflow for poll creation', async () => {
const pollId = randomUUID();
const createdAt = new Date('2021-09-22T07:15:51.247Z').getTime() * 1000; // Convert to debezium microseconds

await expectSuccessfulBackground(
worker,
mockChangeMessage<PollPost>({
after: {
id: pollId,
type: PostType.Poll,
createdAt,
},
op: 'c',
table: 'post',
}),
);

expect(cancelEntityReminderWorkflow).toHaveBeenCalledTimes(0);
expect(runEntityReminderWorkflow).toHaveBeenCalledTimes(1);
expect(runEntityReminderWorkflow).toHaveBeenCalledWith({
entityId: pollId,
entityTableName: 'post',
scheduledAtMs: 0,
delayMs: 14 * 24 * 60 * 60 * 1000, // 14 days in milliseconds (default poll duration)
});
});

it('should cancel entity reminder workflow when poll is deleted', async () => {
const pollId = randomUUID();
const createdAt = new Date('2021-09-22T07:15:51.247Z').getTime() * 1000; // Convert to debezium microseconds

await expectSuccessfulBackground(
worker,
mockChangeMessage<PollPost>({
before: {
id: pollId,
type: PostType.Poll,
createdAt,
deleted: false,
},
after: {
id: pollId,
type: PostType.Poll,
createdAt,
deleted: true,
},
op: 'u',
table: 'post',
}),
);

expect(runEntityReminderWorkflow).toHaveBeenCalledTimes(0);
expect(cancelEntityReminderWorkflow).toHaveBeenCalledTimes(1);
expect(cancelEntityReminderWorkflow).toHaveBeenCalledWith({
entityId: pollId,
entityTableName: 'post',
scheduledAtMs: 0,
delayMs: 14 * 24 * 60 * 60 * 1000, // 14 days in milliseconds (default poll duration)
});
});
});
Loading
Loading