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
167 changes: 164 additions & 3 deletions __tests__/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
DEFAULT_POST_TITLE,
notifyContentRequested,
notifyView,
ONE_DAY_IN_SECONDS,
pickImageUrl,
postScraperOrigin,
updateFlagsStatement,
Expand Down Expand Up @@ -2430,6 +2431,26 @@ describe('mutation deletePost', () => {
await verifyPostDeleted(post.id, loggedUser);
});

it('should allow member to delete their own scheduled post', async () => {
loggedUser = '2';
const source = await con.getRepository(Source).findOneByOrFail({ id: 'a' });
const post = await createSquadWelcomePost(con, source, loggedUser);
await con.getRepository(Post).update(
{ id: post.id },
{
type: PostType.Freeform,
visible: false,
visibleAt: null,
flags: {
visible: false,
scheduledAt: new Date(Date.now() + 60_000).toISOString(),
},
},
);

await verifyPostDeleted(post.id, loggedUser);
});

it('should delete the welcome post by a moderator or an admin', async () => {
loggedUser = '2';
await con.getRepository(SourceMember).save({
Expand Down Expand Up @@ -5509,12 +5530,14 @@ describe('mutation createFreeformPost', () => {
$title: String!
$content: String!
$image: Upload
$scheduledAt: DateTime
) {
createFreeformPost(
sourceId: $sourceId
title: $title
content: $content
image: $image
scheduledAt: $scheduledAt
) {
id
author {
Expand All @@ -5531,6 +5554,9 @@ describe('mutation createFreeformPost', () => {
contentHtml
type
private
flags {
scheduledAt
}
}
}
`;
Expand All @@ -5545,6 +5571,82 @@ describe('mutation createFreeformPost', () => {
await saveSquadFixtures();
});

it('should create a scheduled post', async () => {
loggedUser = '1';
const scheduledAt = new Date(Date.now() + 60_000).toISOString();

const res = await client.mutate(MUTATION, {
variables: { ...params, scheduledAt },
});

expect(res.errors).toBeFalsy();
expect(res.data.createFreeformPost.flags.scheduledAt).toEqual(scheduledAt);

const post = await con.getRepository(Post).findOneByOrFail({
id: res.data.createFreeformPost.id,
});

expect(post.visible).toBe(false);
expect(post.visibleAt).toBeNull();
expect(post.flags.scheduledAt).toEqual(scheduledAt);
});

it('should not create a scheduled post more than 14 days ahead', () => {
loggedUser = '1';

return testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
...params,
scheduledAt: new Date(Date.now() + 15 * ONE_DAY_IN_SECONDS * 1000),
},
},
'GRAPHQL_VALIDATION_FAILED',
'Scheduled time must be within 14 days',
);
});

it('should list scheduled posts', async () => {
loggedUser = '1';
const scheduledAt = new Date(Date.now() + 60_000).toISOString();
const createRes = await client.mutate(MUTATION, {
variables: { ...params, scheduledAt },
});

expect(createRes.errors).toBeFalsy();

const res = await client.query(/* GraphQL */ `
query ScheduledPosts {
scheduledPosts {
edges {
node {
id
title
flags {
scheduledAt
}
}
}
}
}
`);

expect(res.errors).toBeFalsy();
expect(res.data.scheduledPosts.edges).toEqual([
{
node: {
id: createRes.data.createFreeformPost.id,
title: params.title,
flags: {
scheduledAt,
},
},
},
]);
});

it('should not authorize when moderation is required', async () => {
loggedUser = '4';
await testMutationErrorCode(
Expand Down Expand Up @@ -5652,7 +5754,7 @@ describe('mutation createFreeformPost', () => {
.getRepository(Source)
.update({ id: 'a' }, { type: SourceType.Machine });

testMutationErrorCode(
return testMutationErrorCode(
client,
{ mutation: MUTATION, variables: { ...params, sourceId: 'a' } },
'NOT_FOUND',
Expand Down Expand Up @@ -6982,13 +7084,16 @@ describe('mutation createSourcePostModeration', () => {

describe('mutation editPost', () => {
const MUTATION = `
mutation EditPost($id: ID!, $title: String, $content: String, $image: Upload) {
editPost(id: $id, title: $title, content: $content, image: $image) {
mutation EditPost($id: ID!, $title: String, $content: String, $image: Upload, $scheduledAt: DateTime) {
editPost(id: $id, title: $title, content: $content, image: $image, scheduledAt: $scheduledAt) {
id
title
content
contentHtml
type
flags {
scheduledAt
}
source {
id
}
Expand Down Expand Up @@ -7091,6 +7196,62 @@ describe('mutation editPost', () => {
);
});

it('should update scheduled time for a scheduled post', async () => {
loggedUser = '1';
const oldScheduledAt = new Date(Date.now() + 60_000).toISOString();
const scheduledAt = new Date(Date.now() + 120_000).toISOString();

await con.getRepository(Post).update(
{ id: 'p1' },
{
type: PostType.Freeform,
authorId: loggedUser,
visible: false,
visibleAt: null,
flags: {
visible: false,
scheduledAt: oldScheduledAt,
},
},
);

const res = await client.mutate(MUTATION, {
variables: { id: 'p1', scheduledAt },
});

expect(res.errors).toBeFalsy();
expect(res.data.editPost.flags.scheduledAt).toEqual(scheduledAt);

const post = await con.getRepository(Post).findOneByOrFail({ id: 'p1' });
expect(post.visible).toBe(false);
expect(post.flags.scheduledAt).toEqual(scheduledAt);
});

it('should not update scheduled time after post is published', async () => {
loggedUser = '1';
await con.getRepository(Post).update(
{ id: 'p1' },
{
type: PostType.Freeform,
authorId: loggedUser,
visible: true,
},
);

return testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: 'p1',
scheduledAt: new Date(Date.now() + 60_000).toISOString(),
},
},
'GRAPHQL_VALIDATION_FAILED',
'Cannot update scheduled time after post is published',
);
});

it('should update title of the post if it is either freeform or welcome post', async () => {
loggedUser = '1';
await con
Expand Down
34 changes: 34 additions & 0 deletions __tests__/temporal/notifications/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,37 @@ describe('sendEntityReminder activity', () => {
);
});
});

describe('publishScheduledPost activity', () => {
it('should publish a scheduled post and refresh createdAt', async () => {
const scheduledAt = new Date(Date.now() - 1_000).toISOString();
const originalCreatedAt = new Date(Date.now() - 60_000);

await con.getRepository(Post).update(
{ id: 'p1' },
{
visible: false,
visibleAt: null,
createdAt: originalCreatedAt,
flags: {
visible: false,
scheduledAt,
},
},
);

await env.run(activities.publishScheduledPost, {
postId: 'p1',
scheduledAt,
});

const post = await con.getRepository(Post).findOneByOrFail({ id: 'p1' });

expect(post.visible).toBe(true);
expect(post.visibleAt).toBeInstanceOf(Date);
expect(post.createdAt.getTime()).toBeGreaterThan(
originalCreatedAt.getTime(),
);
expect(post.flags).toEqual({ visible: true });
});
});
65 changes: 65 additions & 0 deletions __tests__/temporal/notifications/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import {
cancelReminderWorkflow,
getEntityReminderWorkflowId,
getReminderWorkflowId,
getScheduledPostPublishWorkflowId,
runEntityReminderWorkflow,
runReminderWorkflow,
runScheduledPostPublishWorkflow,
cancelScheduledPostPublishWorkflow,
} from '../../../src/temporal/notifications/utils';
import { createMockTemporalClient } from '../../helpers';

Expand Down Expand Up @@ -194,3 +197,65 @@ describe('cancelEntityReminderWorkflow', () => {
expect(mock.terminate).not.toHaveBeenCalled();
});
});

describe('getScheduledPostPublishWorkflowId', () => {
it('should generate scheduled post workflow id', () => {
const params = {
postId: 'p1',
scheduledAt: '2026-07-02T09:00:00.000Z',
};

expect(getScheduledPostPublishWorkflowId(params)).toBe(
`notification:scheduled-post:p1:${new Date(params.scheduledAt).getTime()}`,
);
});
});

describe('runScheduledPostPublishWorkflow', () => {
it('should start scheduled post workflow', async () => {
mock.describe.mockRejectedValueOnce(notFoundError());
mock.start.mockResolvedValueOnce({ describe: mock.describe });

const params = {
postId: 'p1',
scheduledAt: new Date(Date.now() + 10_000).toISOString(),
};

const result = await runScheduledPostPublishWorkflow(params);

expect(result).toBeDefined();
expect(mock.start).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
workflowId: getScheduledPostPublishWorkflowId(params),
taskQueue: 'notification-queue',
}),
);
});

it('should not start scheduled post workflow when workflow exists', async () => {
mock.describe.mockResolvedValueOnce({ status: { name: 'RUNNING' } });

const result = await runScheduledPostPublishWorkflow({
postId: 'p1',
scheduledAt: new Date(Date.now() + 10_000).toISOString(),
});

expect(result).toBeUndefined();
expect(mock.start).not.toHaveBeenCalled();
});
});

describe('cancelScheduledPostPublishWorkflow', () => {
it('should cancel scheduled post workflow', async () => {
mock.describe.mockResolvedValueOnce({ status: { name: 'RUNNING' } });
mock.terminate.mockResolvedValueOnce(undefined);

await cancelScheduledPostPublishWorkflow({
postId: 'p1',
scheduledAt: '2026-07-02T09:00:00.000Z',
});

expect(mock.terminate).toHaveBeenCalled();
});
});
Loading
Loading