Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
f7e4d91
feat: add dedupKey support for notifications
ilasw Sep 22, 2025
234b6d0
Merge branch 'main' into mi-1043-multiple-squad-posting
ilasw Sep 23, 2025
11a5d71
feat: add support for warning reasons in SourcePostModeration flags
ilasw Sep 23, 2025
5bd9cce
feat: add support for multiple source posts creation with deduplicati…
ilasw Sep 23, 2025
512dadb
refactor: better post deduplication checks
ilasw Sep 23, 2025
c5f0e74
refactor: added isMultiplePosting check && removed unused context params
ilasw Sep 24, 2025
646ef2c
refactor: added transaction scope for post creation
ilasw Sep 24, 2025
83e08f1
Merge branch 'main' of https://github.com/dailydotdev/daily-api into …
ilasw Sep 24, 2025
6b237f2
feat: added dedupKey index and improved multiple source post creation…
ilasw Sep 25, 2025
4d80092
Merge branch 'main' of https://github.com/dailydotdev/daily-api into …
ilasw Sep 25, 2025
aa21828
test: clean up test data after each posts test run
ilasw Sep 25, 2025
c466221
test: updated moderation item tests to check falsy warningReason and …
ilasw Sep 25, 2025
65fcbe8
refactor: revert con to ctx reducing changes
ilasw Sep 25, 2025
a641e60
fix: wrong migration
ilasw Sep 25, 2025
ddb96ef
fix: missing optional image in schema
ilasw Sep 25, 2025
544afbf
fix: revert image field in post schema
ilasw Sep 25, 2025
23b9ede
feat: added externalLink and commentary fields to post schema and upd…
ilasw Sep 25, 2025
0c4bfac
Merge branch 'main' into mi-1043-multiple-squad-posting
AmarTrebinjac Sep 25, 2025
79c49c6
feat: added missing tests and force dedupKey on create shared post fu…
ilasw Sep 26, 2025
e76ce71
Merge remote-tracking branch 'origin/mi-1043-multiple-squad-posting' …
ilasw Sep 26, 2025
8774724
Merge branch 'main' into mi-1043-multiple-squad-posting
ilasw Sep 26, 2025
4b74c9f
Merge branch 'main' into mi-1043-multiple-squad-posting
ilasw Sep 26, 2025
11c58fd
fix: replaced safeParse with parse in posts schema and updated tests
ilasw Sep 26, 2025
3a8a82f
Merge remote-tracking branch 'origin/mi-1043-multiple-squad-posting' …
ilasw Sep 26, 2025
ace07c3
Merge branch 'main' of https://github.com/dailydotdev/daily-api into …
ilasw Sep 26, 2025
fc92c79
refactor: simplified query builders for post deduplication check
ilasw Sep 26, 2025
ae49fbc
Merge branch 'main' of https://github.com/dailydotdev/daily-api into …
ilasw Sep 29, 2025
76288eb
refactor: added transaction and EntityManager support in multiple sou…
ilasw Sep 29, 2025
a89e596
feat: added types for multiple sources post results and item types
ilasw Sep 29, 2025
76a3734
fix: linter
ilasw Sep 29, 2025
9d3a222
Merge branch 'main' into mi-1043-multiple-squad-posting
ilasw Sep 29, 2025
9713a0a
fix: uniqueKey generation for notifications and updated tests
ilasw Sep 30, 2025
4175c0b
refactor: renamed isMultiplePosting to isMultiPost
ilasw Sep 30, 2025
08dc152
refactor: renamed createMultipleSourcesPost to createPostInMultipleSo…
ilasw Sep 30, 2025
26ab10c
Merge branch 'main' into mi-1043-multiple-squad-posting
ilasw Sep 30, 2025
c01e373
feat: add multiple squads posting for polls (#3175)
ilasw Oct 1, 2025
8ff6199
Merge branch 'main' of https://github.com/dailydotdev/daily-api into …
ilasw Oct 2, 2025
b79064c
feat: added external link support for multiple squad posts
ilasw Oct 2, 2025
b68902f
refactor: simplified entityManager usage in post creation functions
ilasw Oct 2, 2025
f894315
refactor: replaced getOne with getExists
ilasw Oct 2, 2025
ae3f7b0
test: added test to ensure no duplicate notifications for posts with …
ilasw Oct 2, 2025
3f79f1d
Merge branch 'main' into mi-1043-multiple-squad-posting
ilasw Oct 2, 2025
2e1e971
fix: removed unnecessary checks for post
ilasw Oct 2, 2025
dc74b90
Merge remote-tracking branch 'origin/mi-1043-multiple-squad-posting' …
ilasw Oct 2, 2025
4dadd0d
Merge branch 'main' into mi-1043-multiple-squad-posting
ilasw Oct 3, 2025
9bfbd16
refactor: updated duplication checks
ilasw Oct 3, 2025
f8f4c9a
Merge remote-tracking branch 'origin/mi-1043-multiple-squad-posting' …
ilasw Oct 3, 2025
139b1bf
Merge branch 'main' into mi-1043-multiple-squad-posting
ilasw Oct 3, 2025
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
372 changes: 372 additions & 0 deletions __tests__/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
PostModerationReason,
SourcePostModeration,
SourcePostModerationStatus,
WarningReason,
} from '../src/entity/SourcePostModeration';
import { generateUUID } from '../src/ids';
import { GQLResponse } from 'mercurius-integration-testing';
Expand Down Expand Up @@ -2883,6 +2884,377 @@ describe('mutation editSharePost', () => {
});
});

describe('mutation createMultipleSourcePosts', () => {
const MUTATION = /* GraphQL */ `
mutation CreateMultipleSourcePosts(
$sourceIds: [ID!]!
$title: String
$commentary: String
$imageUrl: String
$content: String
$image: Upload
$sharedPostId: ID
$externalLink: String
) {
createMultipleSourcePosts(
sourceIds: $sourceIds
title: $title
commentary: $commentary
imageUrl: $imageUrl
content: $content
image: $image
sharedPostId: $sharedPostId
externalLink: $externalLink
) {
id
sourceId
type
}
}
`;

const freeformParams = {
sourceIds: ['squad', 'm'],
title: 'Multi-squad post title',
content: 'This is a multi-squad post content',
};

const shareParams = {
sourceIds: ['squad', 'm'],
sharedPostId: 'p1', // sharing existing post
};

beforeEach(async () => {
await con.getRepository(SourceMember).save([
{
userId: '1',
sourceId: 'squad',
role: SourceMemberRoles.Member,
referralToken: 'rt1-s',
},
{
userId: '1',
sourceId: 'm',
role: SourceMemberRoles.Member,
referralToken: 'rt1-m',
},
]);
});

afterEach(async () => {
await con.getRepository(Post).deleteAll();
await con.getRepository(SourcePostModeration).deleteAll();
await con.getRepository(SourceMember).deleteAll();
});

describe('authorization', () => {
it('should not authorize when not logged in', () =>
testMutationErrorCode(
client,
{ mutation: MUTATION, variables: freeformParams },
'UNAUTHENTICATED',
));

it('should throw error when user has no permission to post in any of the sources', async () => {
loggedUser = '2'; // user not a member of any sources
return testMutationErrorCode(
client,
{ mutation: MUTATION, variables: freeformParams },
'FORBIDDEN',
);
});

it('should handle mixed permissions with some sources allowed, others require moderation', async () => {
loggedUser = '1';
const mixedParams = {
sourceIds: ['squad', 'm'],
title: 'Mixed permissions post',
content: 'Testing mixed permissions',
};

const res = await client.mutate<
{
createMultipleSourcePosts: [
{
id: string;
sourceId: string;
type: PostType;
},
];
},
typeof mixedParams
>(MUTATION, { variables: mixedParams });
expect(res.errors).toBeFalsy();
expect(res.data.createMultipleSourcePosts).toHaveLength(2);

// Check that one is a direct post and one is a moderation item
const results = res.data.createMultipleSourcePosts;
const postTypes = results.map((r) => r.type).sort();
expect(postTypes).toEqual(['moderationItem', 'post']);
});
});

describe('input validation', () => {
it('should throw error when sourceIds array is empty', async () => {
loggedUser = '1';
return testMutationErrorCode(
client,
{ mutation: MUTATION, variables: { ...freeformParams, sourceIds: [] } },
'GRAPHQL_VALIDATION_FAILED',
);
});

it('should throw error when sourceIds is null', async () => {
loggedUser = '1';
return testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: { ...freeformParams, sourceIds: null },
},
'GRAPHQL_VALIDATION_FAILED',
);
});

it('should throw error when title exceeds maximum length', async () => {
loggedUser = '1';
const longTitle = 'a'.repeat(251); // exceeds 250 character limit
return testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: { ...freeformParams, title: longTitle },
},
'GRAPHQL_VALIDATION_FAILED',
);
});

it('should throw error when content exceeds maximum length', async () => {
loggedUser = '1';
const longContent = 'a'.repeat(10001); // exceeds 10000 character limit
return testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: { ...freeformParams, content: longContent },
},
'GRAPHQL_VALIDATION_FAILED',
);
});

it('should throw error when trying to share non-existent post', async () => {
loggedUser = '1';
return testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: { ...shareParams, sharedPostId: 'nonexistent' },
},
'NOT_FOUND',
);
});

it('should handle source not found error', async () => {
loggedUser = '1';
const invalidParams = {
sourceIds: ['nonexistent'],
title: 'Test post',
content: 'Test content',
};

return testMutationErrorCode(
client,
{ mutation: MUTATION, variables: invalidParams },
'FORBIDDEN',
);
});
});

describe('freeform post creation', () => {
it('should successfully create freeform posts in multiple squads', async () => {
loggedUser = '1';
const res = await client.mutate(MUTATION, { variables: freeformParams });

expect(res.errors).toBeFalsy();
expect(res.data.createMultipleSourcePosts).toHaveLength(2);

const [first, second] = res.data.createMultipleSourcePosts;
expect(first.type).toBe('post');
expect(first.sourceId).toBe('squad');
expect(second.type).toBe('moderationItem');
expect(second.sourceId).toBe('m');

// Verify posts were actually created
const [post, moderationItem] = await Promise.all([
await con.getRepository(FreeformPost).findOneByOrFail({ id: first.id }),
await con.getRepository(SourcePostModeration).findOneOrFail({
select: ['sourceId', 'createdById', 'title', 'content', 'status'],
where: { id: second.id },
}),
]);

expect(post).toStrictEqual(
expect.objectContaining({
sourceId: 'squad',
authorId: '1',
title: freeformParams.title,
content: freeformParams.content,
}),
);
expect(moderationItem).toStrictEqual(
expect.objectContaining({
sourceId: 'm',
createdById: '1',
title: freeformParams.title,
content: freeformParams.content,
status: SourcePostModerationStatus.Pending,
}),
);
});

it('should handle single squad posting', async () => {
loggedUser = '1';
const singleParams = { ...freeformParams, sourceIds: ['squad'] };
const res = await client.mutate(MUTATION, { variables: singleParams });

expect(res.errors).toBeFalsy();
expect(res.data.createMultipleSourcePosts).toHaveLength(1);
const [post] = res.data.createMultipleSourcePosts;
expect(post.sourceId).toBe('squad');
expect(post.type).toBe('post');
});
});

describe('share post creation', () => {
it('should successfully share post to multiple squads', async () => {
loggedUser = '1';
const res = await client.mutate(MUTATION, { variables: shareParams });

expect(res.errors).toBeFalsy();
expect(res.data.createMultipleSourcePosts).toHaveLength(2);

const [first, second] = res.data.createMultipleSourcePosts;
expect(first.type).toBe('post');
expect(second.type).toBe('moderationItem');

// Verify posts were actually created
const [post, moderationItem] = await Promise.all([
await con.getRepository(SharePost).findOneOrFail({
where: { id: first.id },
select: ['sourceId', 'authorId', 'sharedPostId'],
}),
await con.getRepository(SourcePostModeration).findOneOrFail({
select: ['sourceId', 'createdById', 'status', 'sharedPostId'],
where: { id: second.id },
}),
]);

expect(post).toEqual({
sourceId: 'squad',
authorId: '1',
sharedPostId: 'p1',
});
expect(moderationItem).toEqual(
expect.objectContaining({
sourceId: 'm',
createdById: '1',
sharedPostId: 'p1',
status: SourcePostModerationStatus.Pending,
}),
);
});

it('should handle share post with commentary', async () => {
loggedUser = '1';
const title = 'My comment';
const shareWithCommentary = {
...shareParams,
title,
};
const res = await client.mutate(MUTATION, {
variables: shareWithCommentary,
});

expect(res.errors).toBeFalsy();

const [first, second] = res.data.createMultipleSourcePosts;
expect(first.type).toBe('post');
expect(second.type).toBe('moderationItem');

// Verify posts were actually created
const [post, moderationItem] = await Promise.all([
await con.getRepository(SharePost).findOneOrFail({
where: { id: first.id },
select: ['sourceId', 'authorId', 'sharedPostId', 'title'],
}),
await con.getRepository(SourcePostModeration).findOneOrFail({
select: [
'sourceId',
'createdById',
'title',
'status',
'sharedPostId',
],
where: { id: second.id },
}),
]);

expect(post).toEqual({
sourceId: 'squad',
authorId: '1',
sharedPostId: 'p1',
title,
});
expect(moderationItem).toEqual(
expect.objectContaining({
sourceId: 'm',
createdById: '1',
sharedPostId: 'p1',
status: SourcePostModerationStatus.Pending,
title,
}),
);
});
});

describe('warning reasons', () => {
it('should add warning reason when posting in multiple squads', async () => {
loggedUser = '1';
const res = await client.mutate(MUTATION, { variables: freeformParams });
const addedModerationItem = res.data.createMultipleSourcePosts
.filter((item: { type: string }) => item.type === 'moderationItem')
.at(0);

const moderationItem = await con
.getRepository(SourcePostModeration)
.findOneOrFail({
where: { id: addedModerationItem.id },
select: ['flags', 'createdBy', 'sourceId'],
});

expect(moderationItem.flags.warningReason).toEqual(
WarningReason.MultipleSquadPost,
);
});

it('should not add warning reason when posting in single squad', async () => {
loggedUser = '1';
const singleParams = { ...freeformParams, sourceIds: ['m'] };
const res = await client.mutate(MUTATION, { variables: singleParams });
const [addedModerationItem] = res.data.createMultipleSourcePosts;
const moderationItem = await con
.getRepository(SourcePostModeration)
.findOneOrFail({
where: { id: addedModerationItem.id },
select: ['flags', 'createdById', 'sourceId'],
});
expect(moderationItem.flags.warningReason).toBeFalsy();
expect(moderationItem.createdById).toEqual('1');
expect(moderationItem.sourceId).toEqual('m');
});
});
});

describe('mutation viewPost', () => {
const MUTATION = `
mutation ViewPost($id: ID!) {
Expand Down
Loading
Loading