Skip to content
Draft
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
170 changes: 170 additions & 0 deletions __tests__/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
type GraphQLTestingState,
} from './helpers';
import {
Achievement,
AchievementEventType,
AchievementType,
Quest,
QuestEventType,
QuestReward,
Expand All @@ -17,6 +20,7 @@ import {
User,
} from '../src/entity';
import {
UserAchievement,
UserQuest,
UserQuestProfile,
UserQuestStatus,
Expand Down Expand Up @@ -121,6 +125,8 @@ beforeEach(async () => {
loggedUser = null;
isPlus = false;

await con.createQueryBuilder().delete().from(UserAchievement).execute();
await con.createQueryBuilder().delete().from(Achievement).execute();
await con.createQueryBuilder().delete().from(UserQuest).execute();
await con.createQueryBuilder().delete().from(UserQuestProfile).execute();
await con.createQueryBuilder().delete().from(QuestRotation).execute();
Expand Down Expand Up @@ -327,4 +333,168 @@ describe('claimQuestReward mutation', () => {
expect(res.errors).toHaveLength(1);
expect(res.errors?.[0]?.message).toBe('Quest is not completed yet');
});

it('should increment achievement progress on claim', async () => {
const now = new Date();
loggedUser = questUserId;

const achievementId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
await con.getRepository(Achievement).save({
id: achievementId,
name: 'Quest enthusiast test',
description: 'Complete and claim 10 quests',
image: '',
type: AchievementType.Milestone,
eventType: AchievementEventType.QuestClaim,
criteria: { targetCount: 10 },
points: 5,
});

await seedQuest({
userQuestStatus: UserQuestStatus.Completed,
periodStart: new Date(now.getTime() - 60 * 60 * 1000),
periodEnd: new Date(now.getTime() + 60 * 60 * 1000),
});

const res = await client.mutate(CLAIM_QUEST_REWARD_MUTATION, {
variables: { userQuestId },
});

expect(res.errors).toBeUndefined();

const userAchievement = await con
.getRepository(UserAchievement)
.findOneBy({ achievementId, userId: questUserId });

expect(userAchievement).not.toBeNull();
expect(userAchievement?.progress).toBe(1);
expect(userAchievement?.unlockedAt).toBeNull();
});

it('should unlock achievement when target reached', async () => {
const now = new Date();
loggedUser = questUserId;

const achievementId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
await con.getRepository(Achievement).save({
id: achievementId,
name: 'Quest enthusiast test',
description: 'Complete and claim 1 quest',
image: '',
type: AchievementType.Milestone,
eventType: AchievementEventType.QuestClaim,
criteria: { targetCount: 1 },
points: 5,
});

await seedQuest({
userQuestStatus: UserQuestStatus.Completed,
periodStart: new Date(now.getTime() - 60 * 60 * 1000),
periodEnd: new Date(now.getTime() + 60 * 60 * 1000),
});

const res = await client.mutate(CLAIM_QUEST_REWARD_MUTATION, {
variables: { userQuestId },
});

expect(res.errors).toBeUndefined();

const userAchievement = await con
.getRepository(UserAchievement)
.findOneBy({ achievementId, userId: questUserId });

expect(userAchievement).not.toBeNull();
expect(userAchievement?.progress).toBeGreaterThanOrEqual(1);
expect(userAchievement?.unlockedAt).not.toBeNull();
});

it('should accumulate progress across multiple claims', async () => {
const now = new Date();
loggedUser = questUserId;

const achievementId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
await con.getRepository(Achievement).save({
id: achievementId,
name: 'Quest enthusiast test',
description: 'Complete and claim 10 quests',
image: '',
type: AchievementType.Milestone,
eventType: AchievementEventType.QuestClaim,
criteria: { targetCount: 10 },
points: 5,
});

const secondQuestId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab';
const secondRotationId = 'dddddddd-dddd-4ddd-8ddd-ddddddddddde';
const secondUserQuestId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeed';

await seedQuest({
userQuestStatus: UserQuestStatus.Completed,
periodStart: new Date(now.getTime() - 60 * 60 * 1000),
periodEnd: new Date(now.getTime() + 60 * 60 * 1000),
});

await client.mutate(CLAIM_QUEST_REWARD_MUTATION, {
variables: { userQuestId },
});

await saveFixtures(con, Quest, [
{
id: secondQuestId,
name: 'Second quest',
description: 'Write 2 comments',
type: QuestType.Daily,
eventType: QuestEventType.CommentCreate,
criteria: { targetCount: 2 },
active: true,
},
]);

await saveFixtures(con, QuestReward, [
{
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2',
questId: secondQuestId,
type: QuestRewardType.XP,
amount: 10,
metadata: {},
},
]);

await saveFixtures(con, QuestRotation, [
{
id: secondRotationId,
questId: secondQuestId,
type: QuestType.Daily,
plusOnly: false,
slot: 2,
periodStart: new Date(now.getTime() - 60 * 60 * 1000),
periodEnd: new Date(now.getTime() + 60 * 60 * 1000),
},
]);

await saveFixtures(con, UserQuest, [
{
id: secondUserQuestId,
rotationId: secondRotationId,
userId: questUserId,
progress: 2,
status: UserQuestStatus.Completed,
completedAt: new Date(),
claimedAt: null,
},
]);

const res = await client.mutate(CLAIM_QUEST_REWARD_MUTATION, {
variables: { userQuestId: secondUserQuestId },
});

expect(res.errors).toBeUndefined();

const userAchievement = await con
.getRepository(UserAchievement)
.findOneBy({ achievementId, userId: questUserId });

expect(userAchievement).not.toBeNull();
expect(userAchievement?.progress).toBe(2);
});
});
13 changes: 13 additions & 0 deletions src/common/achievement/retroactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,18 @@ const handleSharePostsClicked: RetroactiveHandler = async (con, userIds) => {
return toProgressMap(rows);
};

const handleQuestClaim: RetroactiveHandler = async (con, userIds) => {
const rows = await con.query(
`SELECT "userId", COUNT(*)::int AS count
FROM user_quest
WHERE "userId" = ANY($1) AND status = 'claimed'
GROUP BY "userId"`,
[userIds],
);

return toProgressMap(rows);
};

const handlers: Partial<Record<AchievementEventType, RetroactiveHandler>> = {
[AchievementEventType.ProfileImageUpdate]: handleProfileImageUpdate,
[AchievementEventType.ProfileCoverUpdate]: handleProfileCoverUpdate,
Expand Down Expand Up @@ -561,6 +573,7 @@ const handlers: Partial<Record<AchievementEventType, RetroactiveHandler>> = {
[AchievementEventType.PostImpressions]: handlePostImpressions,
[AchievementEventType.ShareClickMilestone]: handleShareClickMilestone,
[AchievementEventType.SharePostsClicked]: handleSharePostsClicked,
[AchievementEventType.QuestClaim]: handleQuestClaim,
};

export const syncUsersRetroactiveAchievements = async ({
Expand Down
1 change: 1 addition & 0 deletions src/entity/Achievement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export enum AchievementEventType {
ShareClick = 'share_click',
ShareClickMilestone = 'share_click_milestone',
SharePostsClicked = 'share_posts_clicked',
QuestClaim = 'quest_claim',
}

export interface AchievementCriteria {
Expand Down
58 changes: 58 additions & 0 deletions src/migration/1772800000000-QuestClaimAchievements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class QuestClaimAchievements1772800000000 implements MigrationInterface {
name = 'QuestClaimAchievements1772800000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(/* sql */ `
INSERT INTO "achievement" (
"name",
"description",
"image",
"type",
"eventType",
"criteria",
"points"
)
VALUES
(
'Bright eyed adventurer',
'Complete 10 quests',
'https://media.daily.dev/image/upload/s--HMBjgs_T--/q_auto/v1773743113/achievements/bright_eyed_adventurer',
'milestone',
'quest_claim',
'{"targetCount": 10}',
5
),
(
'On the path',
'Complete 50 quests',
'https://media.daily.dev/image/upload/s--iwqvFWLT--/q_auto/v1773743172/achievements/on_the_path',
'milestone',
'quest_claim',
'{"targetCount": 50}',
10
),
(
'Hero',
'Complete 100 quests',
'https://media.daily.dev/image/upload/s--5WqXv9y7--/q_auto/v1773743176/achievements/heros_quest',
'milestone',
'quest_claim',
'{"targetCount": 100}',
25
)
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(/* sql */ `
DELETE FROM "achievement"
WHERE "name" IN (
'Quest enthusiast',
'Quest conqueror',
'Quest legend'
)
`);
}
}
12 changes: 12 additions & 0 deletions src/schema/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import {
UserTransactionProcessor,
UserTransactionStatus,
} from '../entity/user/UserTransaction';

import {
AchievementEventType,
checkAchievementProgress,
} from '../common/achievement';
import { NotFoundError } from '../errors';
import { redisPubSub } from '../redis';

Expand Down Expand Up @@ -540,6 +545,13 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
updatedAt: now,
});

await checkAchievementProgress(
ctx.con,
ctx.log,
ctx.userId,
AchievementEventType.QuestClaim,
);

return dashboard;
},
},
Expand Down
Loading