@@ -952,3 +952,151 @@ describe("HTTP API: achievement progress endpoint shape", () => {
952952 expect ( byName [ "OneOfEach" ] . requiredCount ) . toBe ( 2 ) ;
953953 } ) ;
954954} ) ;
955+
956+ // ─── N_OF_THRESHOLDS criteria ────────────────────────────────────────────────
957+
958+ describe ( "Criteria: n_of_thresholds" , ( ) => {
959+ let t : ReturnType < typeof createTestContext > ;
960+ let userId : Id < "users" > ;
961+ let challengeId : Id < "challenges" > ;
962+ let runTypeId : Id < "activityTypes" > ;
963+ let cycleTypeId : Id < "activityTypes" > ;
964+ let swimTypeId : Id < "activityTypes" > ;
965+ let rowTypeId : Id < "activityTypes" > ;
966+ let tWithAuth : any ;
967+ const EMAIL = "triathlete@example.com" ;
968+
969+ beforeEach ( async ( ) => {
970+ t = createTestContext ( ) ;
971+ userId = await createTestUser ( t , { email : EMAIL } ) ;
972+ tWithAuth = t . withIdentity ( { subject : "sub-triathlete" , email : EMAIL } ) ;
973+ challengeId = await createTestChallenge ( t , userId ) ;
974+ runTypeId = await createTestActivityType ( t , challengeId , {
975+ name : "Outdoor Run" ,
976+ scoringConfig : { type : "unit_based" , pointsPerUnit : 8 , unit : "miles" } ,
977+ } ) ;
978+ cycleTypeId = await createTestActivityType ( t , challengeId , {
979+ name : "Outdoor Cycling" ,
980+ scoringConfig : { type : "unit_based" , pointsPerUnit : 2.6 , unit : "miles" } ,
981+ } ) ;
982+ swimTypeId = await createTestActivityType ( t , challengeId , {
983+ name : "Swimming" ,
984+ scoringConfig : { type : "unit_based" , pointsPerUnit : 33 , unit : "miles" } ,
985+ } ) ;
986+ rowTypeId = await createTestActivityType ( t , challengeId , {
987+ name : "Rowing" ,
988+ scoringConfig : { type : "unit_based" , pointsPerUnit : 3.75 , unit : "kilometers" } ,
989+ } ) ;
990+ await createTestParticipation ( t , userId , challengeId ) ;
991+ } ) ;
992+
993+ it ( "awards when N of the thresholds are met (3 of 4)" , async ( ) => {
994+ await createTestAchievement ( t , challengeId , {
995+ criteriaType : "n_of_thresholds" ,
996+ requiredCount : 3 ,
997+ requirements : [
998+ { activityTypeId : runTypeId , metric : "distance_miles" , threshold : 26.2 } ,
999+ { activityTypeId : cycleTypeId , metric : "distance_miles" , threshold : 112 } ,
1000+ { activityTypeId : swimTypeId , metric : "distance_miles" , threshold : 2.4 } ,
1001+ { activityTypeId : rowTypeId , metric : "distance_km" , threshold : 42.2 } ,
1002+ ] ,
1003+ } ) ;
1004+
1005+ // Meet 2 of 4 — not enough
1006+ await logActivity ( tWithAuth , challengeId , runTypeId , { miles : 26.5 } ) ;
1007+ await logActivity ( tWithAuth , challengeId , swimTypeId , { miles : 3 } ) ;
1008+ let earned = await getEarnedAchievements ( t , userId , challengeId ) ;
1009+ expect ( earned ) . toHaveLength ( 0 ) ;
1010+
1011+ // Meet 3rd threshold — should award
1012+ await logActivity ( tWithAuth , challengeId , cycleTypeId , { miles : 115 } ) ;
1013+ earned = await getEarnedAchievements ( t , userId , challengeId ) ;
1014+ expect ( earned ) . toHaveLength ( 1 ) ;
1015+ } ) ;
1016+
1017+ it ( "does not award when below threshold even if enough types logged" , async ( ) => {
1018+ await createTestAchievement ( t , challengeId , {
1019+ criteriaType : "n_of_thresholds" ,
1020+ requiredCount : 2 ,
1021+ requirements : [
1022+ { activityTypeId : runTypeId , metric : "distance_miles" , threshold : 26.2 } ,
1023+ { activityTypeId : cycleTypeId , metric : "distance_miles" , threshold : 112 } ,
1024+ { activityTypeId : swimTypeId , metric : "distance_miles" , threshold : 2.4 } ,
1025+ ] ,
1026+ } ) ;
1027+
1028+ // Log all 3 types but only 1 meets its threshold
1029+ await logActivity ( tWithAuth , challengeId , runTypeId , { miles : 26.5 } ) ; // meets 26.2
1030+ await logActivity ( tWithAuth , challengeId , cycleTypeId , { miles : 50 } ) ; // below 112
1031+ await logActivity ( tWithAuth , challengeId , swimTypeId , { miles : 1 } ) ; // below 2.4
1032+
1033+ const earned = await getEarnedAchievements ( t , userId , challengeId ) ;
1034+ expect ( earned ) . toHaveLength ( 0 ) ;
1035+ } ) ;
1036+
1037+ it ( "awards when exactly requiredCount are met" , async ( ) => {
1038+ await createTestAchievement ( t , challengeId , {
1039+ criteriaType : "n_of_thresholds" ,
1040+ requiredCount : 2 ,
1041+ requirements : [
1042+ { activityTypeId : runTypeId , metric : "distance_miles" , threshold : 26.2 } ,
1043+ { activityTypeId : cycleTypeId , metric : "distance_miles" , threshold : 112 } ,
1044+ { activityTypeId : swimTypeId , metric : "distance_miles" , threshold : 2.4 } ,
1045+ ] ,
1046+ } ) ;
1047+
1048+ await logActivity ( tWithAuth , challengeId , runTypeId , { miles : 30 } ) ;
1049+ await logActivity ( tWithAuth , challengeId , swimTypeId , { miles : 3 } ) ;
1050+
1051+ const earned = await getEarnedAchievements ( t , userId , challengeId ) ;
1052+ expect ( earned ) . toHaveLength ( 1 ) ;
1053+ } ) ;
1054+
1055+ it ( "getUserProgress reports correct counts" , async ( ) => {
1056+ const achievementId = await createTestAchievement ( t , challengeId , {
1057+ criteriaType : "n_of_thresholds" ,
1058+ requiredCount : 3 ,
1059+ requirements : [
1060+ { activityTypeId : runTypeId , metric : "distance_miles" , threshold : 26.2 } ,
1061+ { activityTypeId : cycleTypeId , metric : "distance_miles" , threshold : 112 } ,
1062+ { activityTypeId : swimTypeId , metric : "distance_miles" , threshold : 2.4 } ,
1063+ { activityTypeId : rowTypeId , metric : "distance_km" , threshold : 42.2 } ,
1064+ ] ,
1065+ } ) ;
1066+
1067+ // Meet 1 of 4
1068+ await logActivity ( tWithAuth , challengeId , runTypeId , { miles : 27 } ) ;
1069+
1070+ const progress = await tWithAuth . query (
1071+ api . queries . achievements . getUserProgress ,
1072+ { challengeId } ,
1073+ ) ;
1074+
1075+ const entry = progress . find ( ( p : any ) => p . achievementId === achievementId ) ;
1076+ expect ( entry ) . toBeDefined ( ) ;
1077+ expect ( entry . currentCount ) . toBe ( 1 ) ;
1078+ expect ( entry . requiredCount ) . toBe ( 3 ) ; // not 4 (total requirements)
1079+ expect ( entry . isEarned ) . toBe ( false ) ;
1080+ } ) ;
1081+
1082+ it ( "respects once_per_challenge — no duplicate awards" , async ( ) => {
1083+ await createTestAchievement ( t , challengeId , {
1084+ criteriaType : "n_of_thresholds" ,
1085+ requiredCount : 2 ,
1086+ requirements : [
1087+ { activityTypeId : runTypeId , metric : "distance_miles" , threshold : 26.2 } ,
1088+ { activityTypeId : cycleTypeId , metric : "distance_miles" , threshold : 112 } ,
1089+ { activityTypeId : swimTypeId , metric : "distance_miles" , threshold : 2.4 } ,
1090+ ] ,
1091+ } ) ;
1092+
1093+ // Meet all 3 in separate logs
1094+ await logActivity ( tWithAuth , challengeId , runTypeId , { miles : 30 } ) ;
1095+ await logActivity ( tWithAuth , challengeId , swimTypeId , { miles : 3 } ) ;
1096+ await logActivity ( tWithAuth , challengeId , cycleTypeId , { miles : 120 } ) ;
1097+
1098+ const earned = await getEarnedAchievements ( t , userId , challengeId ) ;
1099+ // Should still only have 1 award (once_per_challenge)
1100+ expect ( earned ) . toHaveLength ( 1 ) ;
1101+ } ) ;
1102+ } ) ;
0 commit comments