From fa50b49957551c63658ee85ecf70edbe4a4a5bcd Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Mon, 6 Jul 2026 17:28:22 +0100 Subject: [PATCH] feat: Add read-only 'my progress' MCP tools to Gamification - EXO-86572 Add 6 read-only campaign-progress tools to GamificationMcpTool, stacked on the existing gamification MCP tools: - list_campaign_badges: badges (levels) of a campaign + score needed - get_my_campaigns: campaigns the user is a member of or owns - list_joinable_campaigns: open public campaigns to join - get_my_points_total: total points earned in a date range / campaign - get_my_score_breakdown: points per campaign for a period - check_quest_availability: pre-check for announce_quest with a reason All tools are hard-scoped to the current user (self identity id / username resolved via the existing helpers; no arbitrary identity accepted). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gamification/mcp/GamificationMcpTool.java | 210 +++++++++++++ .../gamification/mcp/model/BadgeModel.java | 53 ++++ .../mcp/model/PointsTotalModel.java | 48 +++ .../mcp/model/QuestAvailabilityModel.java | 47 +++ .../mcp/model/ScoreBreakdownEntryModel.java | 43 +++ .../main/resources/ai-tool-definitions.json | 148 ++++++++++ .../mcp/GamificationMcpToolKernelTest.java | 1 + .../mcp/GamificationMcpToolTest.java | 276 ++++++++++++++++++ 8 files changed, 826 insertions(+) create mode 100644 services/src/main/java/io/meeds/gamification/mcp/model/BadgeModel.java create mode 100644 services/src/main/java/io/meeds/gamification/mcp/model/PointsTotalModel.java create mode 100644 services/src/main/java/io/meeds/gamification/mcp/model/QuestAvailabilityModel.java create mode 100644 services/src/main/java/io/meeds/gamification/mcp/model/ScoreBreakdownEntryModel.java diff --git a/services/src/main/java/io/meeds/gamification/mcp/GamificationMcpTool.java b/services/src/main/java/io/meeds/gamification/mcp/GamificationMcpTool.java index c5cf533cd8..81d033b05d 100644 --- a/services/src/main/java/io/meeds/gamification/mcp/GamificationMcpTool.java +++ b/services/src/main/java/io/meeds/gamification/mcp/GamificationMcpTool.java @@ -21,9 +21,11 @@ import java.time.LocalDate; import java.time.ZoneId; import java.util.ArrayList; +import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -40,15 +42,22 @@ import io.meeds.gamification.constant.EntityType; import io.meeds.gamification.constant.EntityVisibility; import io.meeds.gamification.constant.IdentityType; +import io.meeds.gamification.constant.Period; import io.meeds.gamification.constant.RealizationStatus; import io.meeds.gamification.constant.RecurrenceType; import io.meeds.gamification.mcp.model.AnnouncementModel; +import io.meeds.gamification.mcp.model.BadgeModel; import io.meeds.gamification.mcp.model.CampaignModel; import io.meeds.gamification.mcp.model.LeaderboardEntryModel; +import io.meeds.gamification.mcp.model.PointsTotalModel; +import io.meeds.gamification.mcp.model.QuestAvailabilityModel; import io.meeds.gamification.mcp.model.QuestModel; import io.meeds.gamification.mcp.model.RealizationModel; +import io.meeds.gamification.mcp.model.ScoreBreakdownEntryModel; import io.meeds.gamification.mcp.model.ScoreModel; import io.meeds.gamification.model.Announcement; +import io.meeds.gamification.model.BadgeDTO; +import io.meeds.gamification.model.PiechartLeaderboard; import io.meeds.gamification.model.ProfileReputation; import io.meeds.gamification.model.ProgramDTO; import io.meeds.gamification.model.RealizationDTO; @@ -58,10 +67,13 @@ import io.meeds.gamification.model.filter.ProgramFilter; import io.meeds.gamification.model.filter.RealizationFilter; import io.meeds.gamification.model.filter.RuleFilter; +import io.meeds.gamification.rest.model.RealizationValidityContext; import io.meeds.gamification.service.AnnouncementService; +import io.meeds.gamification.service.BadgeService; import io.meeds.gamification.service.ProgramService; import io.meeds.gamification.service.RealizationService; import io.meeds.gamification.service.RuleService; +import io.meeds.gamification.utils.Utils; import io.meeds.mcp.server.plugin.McpToolPlugin; /** @@ -87,17 +99,21 @@ public class GamificationMcpTool implements McpToolPlugin { private final AnnouncementService announcementService; + private final BadgeService badgeService; + private final IdentityManager identityManager; public GamificationMcpTool(ProgramService programService, RuleService ruleService, RealizationService realizationService, AnnouncementService announcementService, + BadgeService badgeService, IdentityManager identityManager) { this.programService = programService; this.ruleService = ruleService; this.realizationService = realizationService; this.announcementService = announcementService; + this.badgeService = badgeService; this.identityManager = identityManager; } @@ -289,6 +305,120 @@ public List listMyAnnouncements(Long questId, return result; } + /** + * Lists the badges (levels) of a campaign and the score needed to reach each. + * A badge is a milestone unlocked once the user accumulates enough campaign + * points; the returned {@code level} ranks the badges by that score threshold. + * The campaign is view-gated so a campaign the user cannot see is rejected. + */ + public List listCampaignBadges(Long campaignId) throws IllegalAccessException, ObjectNotFoundException { + ProgramDTO program = resolveProgram(campaignId); + List badges = badgeService.findEnabledBadgesByProgramId(program.getId()); + List sorted = new ArrayList<>(badges == null ? List.of() : badges); + sorted.sort(Comparator.comparingInt(BadgeDTO::getNeededScore)); + List result = new ArrayList<>(); + int level = 1; + for (BadgeDTO badge : sorted) { + result.add(new BadgeModel(badge.getId() == null ? 0 : badge.getId(), + badge.getTitle(), + badge.getDescription(), + level++, + badge.getNeededScore(), + badge.getIconFileId())); + } + return result; + } + + /** + * Lists the campaigns the current user is a member of or owns (as opposed to + * list_campaigns which lists every campaign visible to the user). This is + * always scoped to the current user. + */ + public List getMyCampaigns(Integer limit, Integer offset) { + String currentUser = getCurrentUserName(); + int clampedOffset = clampOffset(offset); + int clampedLimit = clampLimit(limit); + Set ids = new LinkedHashSet<>(); + List memberIds = programService.getMemberProgramIds(currentUser, clampedOffset, clampedLimit); + List ownedIds = programService.getOwnedProgramIds(currentUser, clampedOffset, clampedLimit); + if (memberIds != null) { + ids.addAll(memberIds); + } + if (ownedIds != null) { + ids.addAll(ownedIds); + } + return resolveCampaigns(ids, currentUser); + } + + /** + * Lists the open (public) campaigns the current user can join and participate + * in. + */ + public List listJoinableCampaigns(Integer limit, Integer offset) { + String currentUser = getCurrentUserName(); + List ids = programService.getPublicProgramIds(clampOffset(offset), clampLimit(limit)); + return resolveCampaigns(ids == null ? Set.of() : new LinkedHashSet<>(ids), currentUser); + } + + /** + * Returns the total gamification points the current user earned within a date + * range, optionally scoped to a single campaign. Always scoped to the current + * user. For the per-event list use list_my_realizations. + */ + public PointsTotalModel getMyPointsTotal(String fromDate, String toDate, Long campaignId) { + Date from = parseDate(fromDate, "from_date"); + Date to = parseDate(toDate, "to_date"); + Long programId = (campaignId != null && campaignId > 0) ? campaignId : null; + long total = realizationService.getScoreByIdentityIdAndBetweenDates(String.valueOf(currentUserIdentityId()), + from, + to, + null, + programId); + return new PointsTotalModel(total, StringUtils.trimToNull(fromDate), StringUtils.trimToNull(toDate), programId); + } + + /** + * Breaks down the current user's points by campaign for a period + * (WEEK/MONTH/QUARTER/ALL). Always scoped to the current user. + */ + public List getMyScoreBreakdown(String period) { + String resolvedPeriod = parsePeriod(period); + String earnerId = String.valueOf(currentUserIdentityId()); + Date startDate = Utils.getFromDate(resolvedPeriod, 0); + Date endDate = Utils.getToDate(resolvedPeriod, 0); + List stats = realizationService.getLeaderboardStatsByIdentityId(earnerId, + null, + resolvedPeriod, + startDate, + endDate); + List result = new ArrayList<>(); + if (stats != null) { + String currentUser = getCurrentUserName(); + for (PiechartLeaderboard entry : stats) { + result.add(new ScoreBreakdownEntryModel(entry.getProgramId(), + resolveCampaignLabel(entry, currentUser), + entry.getValue())); + } + } + return result; + } + + /** + * Checks whether the current user can complete/announce a given quest right + * now, and explains why not. This is the pre-check for announce_quest. Always + * evaluated for the current user. + */ + public QuestAvailabilityModel checkQuestAvailability(Long questId) throws IllegalAccessException, ObjectNotFoundException { + RuleDTO rule = resolveRule(questId); + long earnerIdentityId = currentUserIdentityId(); + boolean pending = realizationService.hasPendingRealization(rule.getId(), String.valueOf(earnerIdentityId)); + RealizationValidityContext context = realizationService.getRealizationValidityContext(rule, earnerIdentityId); + boolean isManual = rule.getType() == EntityType.MANUAL; + boolean available = isManual && !pending && context.isValidForIdentity(); + String reason = buildAvailabilityReason(rule, context, isManual, pending); + return new QuestAvailabilityModel(available, reason, rule.getId() == null ? 0 : rule.getId(), rule.getTitle()); + } + // --------------------------------------------------------------- WRITES ---- /** @@ -525,6 +655,86 @@ private boolean canManageCampaign(ProgramDTO program, String username) { return programService.isProgramOwner(program.getId(), username) || programService.canEditProgram(program.getId(), username); } + /** + * Resolves a set of program ids into {@link CampaignModel}s as the given user, + * silently skipping any that no longer exist or that the user can no longer + * view. + */ + private List resolveCampaigns(Set ids, String currentUser) { + List result = new ArrayList<>(); + for (Long id : ids) { + if (id == null || id <= 0) { + continue; + } + try { + result.add(toCampaign(programService.getProgramById(id, currentUser))); + } catch (ObjectNotFoundException | IllegalAccessException e) { + // Skip campaigns that vanished or are no longer viewable by the user. + } + } + return result; + } + + private String resolveCampaignLabel(PiechartLeaderboard entry, String currentUser) { + if (StringUtils.isNotBlank(entry.getLabel())) { + return entry.getLabel(); + } + try { + ProgramDTO program = programService.getProgramById(entry.getProgramId(), currentUser); + return program == null ? null : program.getTitle(); + } catch (ObjectNotFoundException | IllegalAccessException e) { + return null; + } + } + + private String buildAvailabilityReason(RuleDTO rule, RealizationValidityContext context, boolean isManual, boolean pending) { + if (!isManual) { + return "This is an automatic quest: it is awarded by the platform when its event happens, not announced by the user."; + } + if (pending) { + return "You have already announced this quest and it is pending review, so it cannot be announced again yet."; + } + if (!context.isValidNoPrerequisites()) { + if (!context.isValidProgram()) { + return "This quest's campaign is not active."; + } + if (!context.isValidRule()) { + return "This quest is disabled."; + } + if (!context.isValidDates()) { + return "This quest is not open right now (it is outside its start/end dates)."; + } + if (!context.isValidRecurrence()) { + return "You already completed this quest for the current recurrence window; wait for the next occurrence."; + } + if (!context.isValidAudience()) { + return "You are not part of this quest's audience (you may need to join the campaign's space)."; + } + if (!context.isValidWhitelist()) { + return "You are not on this quest's list of allowed participants."; + } + return "This quest is not available to you right now."; + } + if (!context.isValidForIdentity() && context.isValidButLocked()) { + return "You must first complete a prerequisite quest before this one becomes available."; + } + if (!context.isValidForIdentity()) { + return "Your identity is not eligible to earn on this quest."; + } + return "You can complete and announce this quest now."; + } + + private String parsePeriod(String period) { + if (StringUtils.isBlank(period)) { + return Period.ALL.name(); + } + try { + return Period.valueOf(period.trim().toUpperCase()).name(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid period '" + period + "'. Allowed values are: WEEK, MONTH, QUARTER, ALL."); + } + } + private long currentUserIdentityId() { org.exoplatform.social.core.identity.model.Identity identity = identityManager.getOrCreateIdentity(OrganizationIdentityProvider.NAME, getCurrentUserName()); diff --git a/services/src/main/java/io/meeds/gamification/mcp/model/BadgeModel.java b/services/src/main/java/io/meeds/gamification/mcp/model/BadgeModel.java new file mode 100644 index 0000000000..fe3ad14827 --- /dev/null +++ b/services/src/main/java/io/meeds/gamification/mcp/model/BadgeModel.java @@ -0,0 +1,53 @@ +/* + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.gamification.mcp.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A thin view of a campaign badge (a reachable level) exposed to the AI agent. + * The badge title names the level; {@code neededScore} is the total number of + * campaign points a user must accumulate to unlock it. The {@code level} is a + * 1-based rank of the badge within its campaign, ordered by the score needed. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BadgeModel { + + @JsonProperty("badge_id") + private long id; + + private String title; + + private String description; + + private int level; + + @JsonProperty("needed_score") + private int neededScore; + + @JsonProperty("icon_file_id") + private long iconFileId; + +} diff --git a/services/src/main/java/io/meeds/gamification/mcp/model/PointsTotalModel.java b/services/src/main/java/io/meeds/gamification/mcp/model/PointsTotalModel.java new file mode 100644 index 0000000000..385657496e --- /dev/null +++ b/services/src/main/java/io/meeds/gamification/mcp/model/PointsTotalModel.java @@ -0,0 +1,48 @@ +/* + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.gamification.mcp.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The total gamification points the current user earned within a date range, + * optionally scoped to a single campaign. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PointsTotalModel { + + @JsonProperty("total_points") + private long totalPoints; + + @JsonProperty("from_date") + private String fromDate; + + @JsonProperty("to_date") + private String toDate; + + @JsonProperty("campaign_id") + private Long campaignId; + +} diff --git a/services/src/main/java/io/meeds/gamification/mcp/model/QuestAvailabilityModel.java b/services/src/main/java/io/meeds/gamification/mcp/model/QuestAvailabilityModel.java new file mode 100644 index 0000000000..8a14f2cb34 --- /dev/null +++ b/services/src/main/java/io/meeds/gamification/mcp/model/QuestAvailabilityModel.java @@ -0,0 +1,47 @@ +/* + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.gamification.mcp.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Whether the current user can complete/announce a given quest right now, with a + * human-readable reason explaining why not when unavailable. This is the + * pre-check for the announce_quest tool. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class QuestAvailabilityModel { + + private boolean available; + + private String reason; + + @JsonProperty("quest_id") + private long questId; + + @JsonProperty("quest_title") + private String questTitle; + +} diff --git a/services/src/main/java/io/meeds/gamification/mcp/model/ScoreBreakdownEntryModel.java b/services/src/main/java/io/meeds/gamification/mcp/model/ScoreBreakdownEntryModel.java new file mode 100644 index 0000000000..59b4bb85b8 --- /dev/null +++ b/services/src/main/java/io/meeds/gamification/mcp/model/ScoreBreakdownEntryModel.java @@ -0,0 +1,43 @@ +/* + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.gamification.mcp.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One slice of the current user's points breakdown: the points they earned in a + * single campaign over the requested period. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ScoreBreakdownEntryModel { + + @JsonProperty("campaign_id") + private long campaignId; + + private String label; + + private long points; + +} diff --git a/services/src/main/resources/ai-tool-definitions.json b/services/src/main/resources/ai-tool-definitions.json index f8b50ea34f..92916a96c0 100644 --- a/services/src/main/resources/ai-tool-definitions.json +++ b/services/src/main/resources/ai-tool-definitions.json @@ -264,6 +264,154 @@ "openWorldHint": false } }, + { + "name": "list_campaign_badges", + "title": "List campaign badges", + "description": "List the badges (levels) of a campaign and the total number of campaign points needed to reach each one. A campaign is a gamification program; a badge is a milestone level a user unlocks by accumulating enough points in that campaign. Badges are returned ordered by the score needed (level 1 = easiest). Resolve the campaign id with list_campaigns.", + "input_schema": { + "type": "object", + "properties": { + "campaign_id": { + "type": "integer", + "description": "Technical id of the campaign (program) whose badges to list (required)." + } + }, + "required": [ + "campaign_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_my_campaigns", + "title": "Get my campaigns", + "description": "List the campaigns (gamification programs) the current user is a member of or owns. Use this to answer 'my campaigns' questions, as opposed to list_campaigns which lists every campaign the user can see. This is always scoped to the current user.", + "input_schema": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Maximum number of campaigns to return (default 10, max 50)." + }, + "offset": { + "type": "integer", + "description": "Offset for pagination (default 0)." + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "list_joinable_campaigns", + "title": "List joinable campaigns", + "description": "List the open (public) campaigns (gamification programs) the current user can join and participate in to start earning points. Use this to suggest campaigns the user is not yet part of.", + "input_schema": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Maximum number of campaigns to return (default 10, max 50)." + }, + "offset": { + "type": "integer", + "description": "Offset for pagination (default 0)." + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_my_points_total", + "title": "Get my points total", + "description": "Get the total gamification points the current user earned within a date range, optionally within one campaign (program). This returns a single total number; for the per-event breakdown use list_my_realizations, and for the per-campaign split use get_my_score_breakdown. Always scoped to the current user.", + "input_schema": { + "type": "object", + "properties": { + "from_date": { + "type": "string", + "description": "Optional start date in yyyy-MM-dd format (inclusive). Omit for all time." + }, + "to_date": { + "type": "string", + "description": "Optional end date in yyyy-MM-dd format. Omit for up to now." + }, + "campaign_id": { + "type": "integer", + "description": "Optional campaign (program) id to only count points earned in that campaign." + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_my_score_breakdown", + "title": "Get my score breakdown", + "description": "Break down the current user's gamification points by campaign (program) for a period. Returns one entry per campaign with the campaign label and the points earned there. Always scoped to the current user.", + "input_schema": { + "type": "object", + "properties": { + "period": { + "type": "string", + "enum": [ + "WEEK", + "MONTH", + "QUARTER", + "ALL" + ], + "description": "Period to break the points down over (default ALL)." + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "check_quest_availability", + "title": "Check quest availability", + "description": "Check whether the current user can complete/announce a given quest (a gamification action/rule) right now, and explain why not (for example: already announced and pending, outside the quest's dates, current recurrence window already used, a prerequisite quest not yet completed, or the user is not in the audience). Use this as the pre-check before calling announce_quest. Resolve the quest id with list_quests. Always evaluated for the current user.", + "input_schema": { + "type": "object", + "properties": { + "quest_id": { + "type": "integer", + "description": "Technical id of the quest to check (required)." + } + }, + "required": [ + "quest_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, { "name": "create_campaign", "title": "Create a campaign", diff --git a/services/src/test/java/io/meeds/gamification/mcp/GamificationMcpToolKernelTest.java b/services/src/test/java/io/meeds/gamification/mcp/GamificationMcpToolKernelTest.java index 2ca765a899..bf5358ab70 100644 --- a/services/src/test/java/io/meeds/gamification/mcp/GamificationMcpToolKernelTest.java +++ b/services/src/test/java/io/meeds/gamification/mcp/GamificationMcpToolKernelTest.java @@ -50,6 +50,7 @@ public void setUp() throws Exception { ruleService, realizationService, announcementService, + badgeService, identityManager); } diff --git a/services/src/test/java/io/meeds/gamification/mcp/GamificationMcpToolTest.java b/services/src/test/java/io/meeds/gamification/mcp/GamificationMcpToolTest.java index 176fbee4e0..fe3562ce9d 100644 --- a/services/src/test/java/io/meeds/gamification/mcp/GamificationMcpToolTest.java +++ b/services/src/test/java/io/meeds/gamification/mcp/GamificationMcpToolTest.java @@ -46,18 +46,26 @@ import io.meeds.gamification.constant.EntityVisibility; import io.meeds.gamification.constant.RecurrenceType; import io.meeds.gamification.mcp.model.AnnouncementModel; +import io.meeds.gamification.mcp.model.BadgeModel; import io.meeds.gamification.mcp.model.CampaignModel; import io.meeds.gamification.mcp.model.LeaderboardEntryModel; +import io.meeds.gamification.mcp.model.PointsTotalModel; +import io.meeds.gamification.mcp.model.QuestAvailabilityModel; import io.meeds.gamification.mcp.model.QuestModel; import io.meeds.gamification.mcp.model.RealizationModel; +import io.meeds.gamification.mcp.model.ScoreBreakdownEntryModel; import io.meeds.gamification.mcp.model.ScoreModel; import io.meeds.gamification.model.Announcement; +import io.meeds.gamification.model.BadgeDTO; +import io.meeds.gamification.model.PiechartLeaderboard; import io.meeds.gamification.model.ProfileReputation; import io.meeds.gamification.model.ProgramDTO; import io.meeds.gamification.model.RealizationDTO; import io.meeds.gamification.model.RuleDTO; import io.meeds.gamification.model.StandardLeaderboard; +import io.meeds.gamification.rest.model.RealizationValidityContext; import io.meeds.gamification.service.AnnouncementService; +import io.meeds.gamification.service.BadgeService; import io.meeds.gamification.service.ProgramService; import io.meeds.gamification.service.RealizationService; import io.meeds.gamification.service.RuleService; @@ -80,6 +88,8 @@ public class GamificationMcpToolTest { private AnnouncementService announcementService; + private BadgeService badgeService; + private IdentityManager identityManager; private Identity currentIdentity; @@ -92,12 +102,14 @@ public void setUp() { ruleService = mock(RuleService.class); realizationService = mock(RealizationService.class); announcementService = mock(AnnouncementService.class); + badgeService = mock(BadgeService.class); identityManager = mock(IdentityManager.class); currentIdentity = new Identity(USERNAME); tool = new GamificationMcpTool(programService, ruleService, realizationService, announcementService, + badgeService, identityManager) { @Override public Identity getCurrentUserAclIdentity() { @@ -666,4 +678,268 @@ public void cancelQuestAnnouncementInvalidIdFails() { assertThrows(IllegalArgumentException.class, () -> tool.cancelQuestAnnouncement(0L)); } + // --- list_campaign_badges ------------------------------------------------ + + private BadgeDTO badge(long id, String title, int neededScore, long iconFileId) { + BadgeDTO badge = new BadgeDTO(); + badge.setId(id); + badge.setTitle(title); + badge.setDescription("Reach " + neededScore + " points"); + badge.setNeededScore(neededScore); + badge.setIconFileId(iconFileId); + badge.setEnabled(true); + return badge; + } + + @Test + public void listCampaignBadgesOrdersByNeededScore() throws Exception { + when(programService.getProgramById(CAMPAIGN_ID, USERNAME)).thenReturn(program(CAMPAIGN_ID)); + // returned out of order to prove the tool sorts them by needed score + when(badgeService.findEnabledBadgesByProgramId(CAMPAIGN_ID)) + .thenReturn(List.of(badge(2L, "Gold", 300, 20L), badge(1L, "Silver", 100, 10L))); + + List badges = tool.listCampaignBadges(CAMPAIGN_ID); + + assertEquals(2, badges.size()); + assertEquals("Silver", badges.get(0).getTitle()); + assertEquals(1, badges.get(0).getLevel()); + assertEquals(100, badges.get(0).getNeededScore()); + assertEquals(10L, badges.get(0).getIconFileId()); + assertEquals("Gold", badges.get(1).getTitle()); + assertEquals(2, badges.get(1).getLevel()); + } + + @Test + public void listCampaignBadgesEmpty() throws Exception { + when(programService.getProgramById(CAMPAIGN_ID, USERNAME)).thenReturn(program(CAMPAIGN_ID)); + when(badgeService.findEnabledBadgesByProgramId(CAMPAIGN_ID)).thenReturn(List.of()); + + List badges = tool.listCampaignBadges(CAMPAIGN_ID); + + assertTrue(badges.isEmpty()); + } + + @Test + public void listCampaignBadgesUnknownCampaignFails() throws Exception { + when(programService.getProgramById(CAMPAIGN_ID, USERNAME)).thenThrow(new ObjectNotFoundException("missing")); + assertThrows(ObjectNotFoundException.class, () -> tool.listCampaignBadges(CAMPAIGN_ID)); + } + + @Test + public void listCampaignBadgesInvalidIdFails() { + assertThrows(IllegalArgumentException.class, () -> tool.listCampaignBadges(0L)); + } + + // --- get_my_campaigns ---------------------------------------------------- + + @Test + public void getMyCampaignsUnionsMemberAndOwned() throws Exception { + stubCurrentUserIdentity(); + when(programService.getMemberProgramIds(eq(USERNAME), anyInt(), anyInt())).thenReturn(List.of(5L, 6L)); + when(programService.getOwnedProgramIds(eq(USERNAME), anyInt(), anyInt())).thenReturn(List.of(6L, 7L)); + when(programService.getProgramById(5L, USERNAME)).thenReturn(program(5L)); + when(programService.getProgramById(6L, USERNAME)).thenReturn(program(6L)); + when(programService.getProgramById(7L, USERNAME)).thenReturn(program(7L)); + + List campaigns = tool.getMyCampaigns(null, null); + + // 5, 6, 7 deduped (6 appears in both lists) + assertEquals(3, campaigns.size()); + } + + @Test + public void getMyCampaignsSkipsUnviewable() throws Exception { + stubCurrentUserIdentity(); + when(programService.getMemberProgramIds(eq(USERNAME), anyInt(), anyInt())).thenReturn(List.of(5L, 8L)); + when(programService.getOwnedProgramIds(eq(USERNAME), anyInt(), anyInt())).thenReturn(List.of()); + when(programService.getProgramById(5L, USERNAME)).thenReturn(program(5L)); + when(programService.getProgramById(8L, USERNAME)).thenThrow(new IllegalAccessException("denied")); + + List campaigns = tool.getMyCampaigns(10, 0); + + assertEquals(1, campaigns.size()); + assertEquals(5L, campaigns.get(0).getId()); + } + + @Test + public void getMyCampaignsEmpty() { + stubCurrentUserIdentity(); + when(programService.getMemberProgramIds(eq(USERNAME), anyInt(), anyInt())).thenReturn(null); + when(programService.getOwnedProgramIds(eq(USERNAME), anyInt(), anyInt())).thenReturn(null); + + List campaigns = tool.getMyCampaigns(null, null); + + assertTrue(campaigns.isEmpty()); + } + + // --- list_joinable_campaigns --------------------------------------------- + + @Test + public void listJoinableCampaigns() throws Exception { + when(programService.getPublicProgramIds(anyInt(), anyInt())).thenReturn(List.of(5L, 6L)); + when(programService.getProgramById(5L, USERNAME)).thenReturn(program(5L)); + when(programService.getProgramById(6L, USERNAME)).thenReturn(program(6L)); + + List campaigns = tool.listJoinableCampaigns(null, null); + + assertEquals(2, campaigns.size()); + } + + @Test + public void listJoinableCampaignsEmpty() { + when(programService.getPublicProgramIds(anyInt(), anyInt())).thenReturn(null); + + List campaigns = tool.listJoinableCampaigns(null, null); + + assertTrue(campaigns.isEmpty()); + } + + // --- get_my_points_total ------------------------------------------------- + + @Test + public void getMyPointsTotal() { + stubCurrentUserIdentity(); + when(realizationService.getScoreByIdentityIdAndBetweenDates(eq(String.valueOf(OWN_ID)), + any(), + any(), + isNull(), + eq(CAMPAIGN_ID))).thenReturn(240L); + + PointsTotalModel total = tool.getMyPointsTotal("2026-01-01", "2026-02-01", CAMPAIGN_ID); + + assertEquals(240L, total.getTotalPoints()); + assertEquals("2026-01-01", total.getFromDate()); + assertEquals("2026-02-01", total.getToDate()); + assertEquals(Long.valueOf(CAMPAIGN_ID), total.getCampaignId()); + } + + @Test + public void getMyPointsTotalNoDatesNoCampaign() { + stubCurrentUserIdentity(); + when(realizationService.getScoreByIdentityIdAndBetweenDates(eq(String.valueOf(OWN_ID)), + isNull(), + isNull(), + isNull(), + isNull())).thenReturn(500L); + + PointsTotalModel total = tool.getMyPointsTotal(null, null, null); + + assertEquals(500L, total.getTotalPoints()); + assertEquals(null, total.getCampaignId()); + } + + @Test + public void getMyPointsTotalInvalidDateFails() { + stubCurrentUserIdentity(); + assertThrows(IllegalArgumentException.class, () -> tool.getMyPointsTotal("not-a-date", null, null)); + } + + // --- get_my_score_breakdown ---------------------------------------------- + + @Test + public void getMyScoreBreakdown() throws Exception { + stubCurrentUserIdentity(); + PiechartLeaderboard slice = new PiechartLeaderboard(CAMPAIGN_ID, 80L); + when(realizationService.getLeaderboardStatsByIdentityId(eq(String.valueOf(OWN_ID)), isNull(), eq("MONTH"), any(), any())) + .thenReturn(List.of(slice)); + when(programService.getProgramById(CAMPAIGN_ID, USERNAME)).thenReturn(program(CAMPAIGN_ID)); + + List breakdown = tool.getMyScoreBreakdown("MONTH"); + + assertEquals(1, breakdown.size()); + assertEquals(CAMPAIGN_ID, breakdown.get(0).getCampaignId()); + assertEquals(80L, breakdown.get(0).getPoints()); + assertEquals("Campaign 5", breakdown.get(0).getLabel()); + } + + @Test + public void getMyScoreBreakdownDefaultsToAll() { + stubCurrentUserIdentity(); + when(realizationService.getLeaderboardStatsByIdentityId(eq(String.valueOf(OWN_ID)), isNull(), eq("ALL"), isNull(), isNull())) + .thenReturn(List.of()); + + List breakdown = tool.getMyScoreBreakdown(null); + + assertTrue(breakdown.isEmpty()); + } + + @Test + public void getMyScoreBreakdownInvalidPeriodFails() { + stubCurrentUserIdentity(); + assertThrows(IllegalArgumentException.class, () -> tool.getMyScoreBreakdown("DECADE")); + } + + // --- check_quest_availability -------------------------------------------- + + @Test + public void checkQuestAvailabilityAvailable() throws Exception { + stubCurrentUserIdentity(); + RuleDTO existing = rule(QUEST_ID, program(CAMPAIGN_ID)); + when(ruleService.findRuleById(QUEST_ID, USERNAME)).thenReturn(existing); + when(realizationService.hasPendingRealization(QUEST_ID, String.valueOf(OWN_ID))).thenReturn(false); + when(realizationService.getRealizationValidityContext(existing, OWN_ID)).thenReturn(new RealizationValidityContext()); + + QuestAvailabilityModel result = tool.checkQuestAvailability(QUEST_ID); + + assertTrue(result.isAvailable()); + assertEquals(QUEST_ID, result.getQuestId()); + assertEquals("Quest 7", result.getQuestTitle()); + } + + @Test + public void checkQuestAvailabilityPendingNotAvailable() throws Exception { + stubCurrentUserIdentity(); + RuleDTO existing = rule(QUEST_ID, program(CAMPAIGN_ID)); + when(ruleService.findRuleById(QUEST_ID, USERNAME)).thenReturn(existing); + when(realizationService.hasPendingRealization(QUEST_ID, String.valueOf(OWN_ID))).thenReturn(true); + when(realizationService.getRealizationValidityContext(existing, OWN_ID)).thenReturn(new RealizationValidityContext()); + + QuestAvailabilityModel result = tool.checkQuestAvailability(QUEST_ID); + + assertTrue(!result.isAvailable()); + assertTrue(result.getReason().toLowerCase().contains("pending")); + } + + @Test + public void checkQuestAvailabilityOutsideDatesNotAvailable() throws Exception { + stubCurrentUserIdentity(); + RuleDTO existing = rule(QUEST_ID, program(CAMPAIGN_ID)); + when(ruleService.findRuleById(QUEST_ID, USERNAME)).thenReturn(existing); + when(realizationService.hasPendingRealization(QUEST_ID, String.valueOf(OWN_ID))).thenReturn(false); + RealizationValidityContext context = new RealizationValidityContext(); + context.setValidDates(false); + when(realizationService.getRealizationValidityContext(existing, OWN_ID)).thenReturn(context); + + QuestAvailabilityModel result = tool.checkQuestAvailability(QUEST_ID); + + assertTrue(!result.isAvailable()); + assertTrue(result.getReason().toLowerCase().contains("dates")); + } + + @Test + public void checkQuestAvailabilityAutomaticNotAvailable() throws Exception { + stubCurrentUserIdentity(); + RuleDTO existing = rule(QUEST_ID, program(CAMPAIGN_ID)); + existing.setType(EntityType.AUTOMATIC); + when(ruleService.findRuleById(QUEST_ID, USERNAME)).thenReturn(existing); + when(realizationService.hasPendingRealization(QUEST_ID, String.valueOf(OWN_ID))).thenReturn(false); + when(realizationService.getRealizationValidityContext(existing, OWN_ID)).thenReturn(new RealizationValidityContext()); + + QuestAvailabilityModel result = tool.checkQuestAvailability(QUEST_ID); + + assertTrue(!result.isAvailable()); + assertTrue(result.getReason().toLowerCase().contains("automatic")); + } + + @Test + public void checkQuestAvailabilityUnknownQuestFails() throws Exception { + when(ruleService.findRuleById(QUEST_ID, USERNAME)).thenThrow(new ObjectNotFoundException("missing")); + assertThrows(ObjectNotFoundException.class, () -> tool.checkQuestAvailability(QUEST_ID)); + } + + @Test + public void checkQuestAvailabilityInvalidIdFails() { + assertThrows(IllegalArgumentException.class, () -> tool.checkQuestAvailability(0L)); + } + }