@@ -40,9 +40,11 @@ jest.unstable_mockModule('../../src/utils/config.js', () => ({
4040 } ,
4141} ) ) ;
4242
43- const { calculateSenpaiScore, calculateSenseiScore } =
43+ const { calculateSenpaiScore, calculateSenseiScore, checkPromotion , getUserStats } =
4444 await import ( '../../src/services/reputation.js' ) ;
4545
46+ import { Role } from '../../src/types.js' ;
47+
4648describe ( 'Reputation Service' , ( ) => {
4749 beforeEach ( ( ) => {
4850 jest . clearAllMocks ( ) ;
@@ -289,4 +291,209 @@ describe('Reputation Service', () => {
289291 expect ( score . uniqueRequired ) . toBe ( 2 ) ; // CEIL(6 * 0.20) = 2
290292 } ) ;
291293 } ) ;
294+
295+ describe ( 'checkPromotion' , ( ) => {
296+ test ( 'should promote Kohai to Senpai when thresholds met' , async ( ) => {
297+ const guild = createMockGuild ( ) ;
298+ const mockMember = {
299+ id : 'user-1' ,
300+ user : { id : 'user-1' , tag : 'User#0001' } ,
301+ } ;
302+ guild . members . cache . set ( 'user-1' , mockMember ) ;
303+
304+ mockGetUserRole . mockReturnValue ( Role . Kohai ) ;
305+ mockGetRoleCounts . mockReturnValue ( { kohai : 50 , senpai : 70 , sensei : 30 } ) ;
306+ mockGetReactionCount . mockResolvedValue ( 60 ) ;
307+ mockGetUniqueReactors . mockResolvedValue ( Array . from ( { length : 15 } , ( _ , i ) => `reactor-${ i } ` ) ) ;
308+ mockAssignRole . mockResolvedValue ( undefined ) ;
309+ mockInsertRoleHistory . mockResolvedValue ( undefined ) ;
310+ mockSendDM . mockResolvedValue ( true ) ;
311+
312+ const result = await checkPromotion ( guild as any , 'user-1' ) ;
313+
314+ expect ( result . promoted ) . toBe ( true ) ;
315+ expect ( result . oldRole ) . toBe ( Role . Kohai ) ;
316+ expect ( result . newRole ) . toBe ( Role . Senpai ) ;
317+ expect ( mockAssignRole ) . toHaveBeenCalledWith ( guild , 'user-1' , Role . Senpai ) ;
318+ expect ( mockInsertRoleHistory ) . toHaveBeenCalledWith ( 'user-1' , Role . Senpai , 'promotion' ) ;
319+ expect ( mockSendDM ) . toHaveBeenCalled ( ) ;
320+ } ) ;
321+
322+ test ( 'should promote Senpai to Sensei when thresholds met' , async ( ) => {
323+ const guild = createMockGuild ( ) ;
324+ const mockMember = {
325+ id : 'user-1' ,
326+ user : { id : 'user-1' , tag : 'User#0001' } ,
327+ } ;
328+ guild . members . cache . set ( 'user-1' , mockMember ) ;
329+
330+ mockGetUserRole . mockReturnValue ( Role . Senpai ) ;
331+ mockGetRoleCounts . mockReturnValue ( { kohai : 50 , senpai : 70 , sensei : 50 } ) ;
332+ mockGetReactionCount . mockResolvedValue ( 35 ) ;
333+ mockGetUniqueReactors . mockResolvedValue ( Array . from ( { length : 12 } , ( _ , i ) => `sensei-${ i } ` ) ) ;
334+ mockAssignRole . mockResolvedValue ( undefined ) ;
335+ mockInsertRoleHistory . mockResolvedValue ( undefined ) ;
336+ mockSendDM . mockResolvedValue ( true ) ;
337+
338+ const result = await checkPromotion ( guild as any , 'user-1' ) ;
339+
340+ expect ( result . promoted ) . toBe ( true ) ;
341+ expect ( result . oldRole ) . toBe ( Role . Senpai ) ;
342+ expect ( result . newRole ) . toBe ( Role . Sensei ) ;
343+ expect ( mockAssignRole ) . toHaveBeenCalledWith ( guild , 'user-1' , Role . Sensei ) ;
344+ expect ( mockInsertRoleHistory ) . toHaveBeenCalledWith ( 'user-1' , Role . Sensei , 'promotion' ) ;
345+ } ) ;
346+
347+ test ( 'should not promote Kohai when threshold not met' , async ( ) => {
348+ const guild = createMockGuild ( ) ;
349+
350+ mockGetUserRole . mockReturnValue ( Role . Kohai ) ;
351+ mockGetRoleCounts . mockReturnValue ( { kohai : 50 , senpai : 70 , sensei : 30 } ) ;
352+ mockGetReactionCount . mockResolvedValue ( 40 ) ; // Below 50 threshold
353+ mockGetUniqueReactors . mockResolvedValue ( Array . from ( { length : 15 } , ( _ , i ) => `reactor-${ i } ` ) ) ;
354+
355+ const result = await checkPromotion ( guild as any , 'user-1' ) ;
356+
357+ expect ( result . promoted ) . toBe ( false ) ;
358+ expect ( mockAssignRole ) . not . toHaveBeenCalled ( ) ;
359+ expect ( mockInsertRoleHistory ) . not . toHaveBeenCalled ( ) ;
360+ } ) ;
361+
362+ test ( 'should not promote Kohai when unique requirement not met' , async ( ) => {
363+ const guild = createMockGuild ( ) ;
364+
365+ mockGetUserRole . mockReturnValue ( Role . Kohai ) ;
366+ mockGetRoleCounts . mockReturnValue ( { kohai : 50 , senpai : 70 , sensei : 30 } ) ;
367+ mockGetReactionCount . mockResolvedValue ( 60 ) ;
368+ mockGetUniqueReactors . mockResolvedValue ( [ 'reactor-1' , 'reactor-2' ] ) ; // Only 2, need 10
369+
370+ const result = await checkPromotion ( guild as any , 'user-1' ) ;
371+
372+ expect ( result . promoted ) . toBe ( false ) ;
373+ expect ( mockAssignRole ) . not . toHaveBeenCalled ( ) ;
374+ } ) ;
375+
376+ test ( 'should not check promotion for user without role' , async ( ) => {
377+ const guild = createMockGuild ( ) ;
378+
379+ mockGetUserRole . mockReturnValue ( null ) ;
380+
381+ const result = await checkPromotion ( guild as any , 'user-1' ) ;
382+
383+ expect ( result . promoted ) . toBe ( false ) ;
384+ expect ( mockGetReactionCount ) . not . toHaveBeenCalled ( ) ;
385+ } ) ;
386+
387+ test ( 'should not promote Sensei (already at max)' , async ( ) => {
388+ const guild = createMockGuild ( ) ;
389+
390+ mockGetUserRole . mockReturnValue ( Role . Sensei ) ;
391+
392+ const result = await checkPromotion ( guild as any , 'user-1' ) ;
393+
394+ expect ( result . promoted ) . toBe ( false ) ;
395+ expect ( mockGetReactionCount ) . not . toHaveBeenCalled ( ) ;
396+ } ) ;
397+
398+ test ( 'should handle errors gracefully' , async ( ) => {
399+ const guild = createMockGuild ( ) ;
400+
401+ mockGetUserRole . mockReturnValue ( Role . Kohai ) ;
402+ mockGetRoleCounts . mockReturnValue ( { kohai : 50 , senpai : 70 , sensei : 30 } ) ;
403+ mockGetReactionCount . mockRejectedValue ( new Error ( 'Database error' ) ) ;
404+
405+ const result = await checkPromotion ( guild as any , 'user-1' ) ;
406+
407+ expect ( result . promoted ) . toBe ( false ) ;
408+ } ) ;
409+ } ) ;
410+
411+ describe ( 'getUserStats' , ( ) => {
412+ test ( 'should return stats for Kohai with senpaiScore' , async ( ) => {
413+ const guild = createMockGuild ( ) ;
414+
415+ mockGetUserRole . mockReturnValue ( Role . Kohai ) ;
416+ mockGetReactionBreakdown . mockResolvedValue ( {
417+ total : 38 ,
418+ fromKohai : 15 ,
419+ fromSenpai : 18 ,
420+ fromSensei : 5 ,
421+ } ) ;
422+ mockGetRoleCounts . mockReturnValue ( { kohai : 50 , senpai : 70 , sensei : 30 } ) ;
423+ mockGetReactionCount . mockResolvedValue ( 23 ) ;
424+ mockGetUniqueReactors . mockResolvedValue ( Array . from ( { length : 8 } , ( _ , i ) => `reactor-${ i } ` ) ) ;
425+
426+ const stats = await getUserStats ( guild as any , 'user-1' ) ;
427+
428+ expect ( stats . userId ) . toBe ( 'user-1' ) ;
429+ expect ( stats . currentRole ) . toBe ( Role . Kohai ) ;
430+ expect ( stats . breakdown . total ) . toBe ( 38 ) ;
431+ expect ( stats . breakdown . fromSenpai ) . toBe ( 18 ) ;
432+ expect ( stats . senpaiScore ) . toBeDefined ( ) ;
433+ expect ( stats . senpaiScore ?. totalReactions ) . toBe ( 23 ) ;
434+ expect ( stats . senseiScore ) . toBeUndefined ( ) ;
435+ } ) ;
436+
437+ test ( 'should return stats for Senpai with senseiScore' , async ( ) => {
438+ const guild = createMockGuild ( ) ;
439+
440+ mockGetUserRole . mockReturnValue ( Role . Senpai ) ;
441+ mockGetReactionBreakdown . mockResolvedValue ( {
442+ total : 120 ,
443+ fromKohai : 30 ,
444+ fromSenpai : 50 ,
445+ fromSensei : 40 ,
446+ } ) ;
447+ mockGetRoleCounts . mockReturnValue ( { kohai : 50 , senpai : 70 , sensei : 30 } ) ;
448+ mockGetReactionCount . mockResolvedValue ( 25 ) ;
449+ mockGetUniqueReactors . mockResolvedValue ( Array . from ( { length : 5 } , ( _ , i ) => `sensei-${ i } ` ) ) ;
450+
451+ const stats = await getUserStats ( guild as any , 'user-1' ) ;
452+
453+ expect ( stats . userId ) . toBe ( 'user-1' ) ;
454+ expect ( stats . currentRole ) . toBe ( Role . Senpai ) ;
455+ expect ( stats . senseiScore ) . toBeDefined ( ) ;
456+ expect ( stats . senseiScore ?. totalReactions ) . toBe ( 25 ) ;
457+ expect ( stats . senpaiScore ) . toBeUndefined ( ) ;
458+ } ) ;
459+
460+ test ( 'should return stats for Sensei without progression scores' , async ( ) => {
461+ const guild = createMockGuild ( ) ;
462+
463+ mockGetUserRole . mockReturnValue ( Role . Sensei ) ;
464+ mockGetReactionBreakdown . mockResolvedValue ( {
465+ total : 250 ,
466+ fromKohai : 80 ,
467+ fromSenpai : 100 ,
468+ fromSensei : 70 ,
469+ } ) ;
470+
471+ const stats = await getUserStats ( guild as any , 'user-1' ) ;
472+
473+ expect ( stats . userId ) . toBe ( 'user-1' ) ;
474+ expect ( stats . currentRole ) . toBe ( Role . Sensei ) ;
475+ expect ( stats . senpaiScore ) . toBeUndefined ( ) ;
476+ expect ( stats . senseiScore ) . toBeUndefined ( ) ;
477+ } ) ;
478+
479+ test ( 'should return stats for user without role' , async ( ) => {
480+ const guild = createMockGuild ( ) ;
481+
482+ mockGetUserRole . mockReturnValue ( null ) ;
483+ mockGetReactionBreakdown . mockResolvedValue ( {
484+ total : 0 ,
485+ fromKohai : 0 ,
486+ fromSenpai : 0 ,
487+ fromSensei : 0 ,
488+ } ) ;
489+
490+ const stats = await getUserStats ( guild as any , 'user-1' ) ;
491+
492+ expect ( stats . userId ) . toBe ( 'user-1' ) ;
493+ expect ( stats . currentRole ) . toBeNull ( ) ;
494+ expect ( stats . breakdown . total ) . toBe ( 0 ) ;
495+ expect ( stats . senpaiScore ) . toBeUndefined ( ) ;
496+ expect ( stats . senseiScore ) . toBeUndefined ( ) ;
497+ } ) ;
498+ } ) ;
292499} ) ;
0 commit comments