|
1 | 1 | import { Test, TestingModule } from '@nestjs/testing'; |
| 2 | +import { ConfigService } from '@nestjs/config'; |
2 | 3 | import { SybilResistanceService } from './sybil-resistance.service'; |
3 | 4 | import { PrismaService } from '../prisma/prisma.service'; |
4 | 5 | import { NotFoundException } from '@nestjs/common'; |
5 | 6 |
|
6 | 7 | describe('SybilResistanceService', () => { |
7 | 8 | let service: SybilResistanceService; |
8 | 9 | let prisma: any; |
| 10 | + let configService: ConfigService; |
9 | 11 |
|
10 | 12 | // Mock user data |
11 | 13 | const mockUserId = 'test-user-id'; |
@@ -44,11 +46,21 @@ describe('SybilResistanceService', () => { |
44 | 46 | }, |
45 | 47 | }, |
46 | 48 | }, |
| 49 | + { |
| 50 | + provide: ConfigService, |
| 51 | + useValue: { |
| 52 | + get: jest.fn((key: string, defaultValue?: any) => { |
| 53 | + if (key === 'sybil.minClaimsForAccuracyScore') return defaultValue ?? 5; |
| 54 | + return defaultValue; |
| 55 | + }), |
| 56 | + }, |
| 57 | + }, |
47 | 58 | ], |
48 | 59 | }).compile(); |
49 | 60 |
|
50 | 61 | service = module.get<SybilResistanceService>(SybilResistanceService); |
51 | 62 | prisma = module.get<any>(PrismaService); |
| 63 | + configService = module.get<ConfigService>(ConfigService); |
52 | 64 | }); |
53 | 65 |
|
54 | 66 | afterEach(() => { |
@@ -474,6 +486,105 @@ describe('SybilResistanceService', () => { |
474 | 486 | }); |
475 | 487 | }); |
476 | 488 |
|
| 489 | + describe('MIN_CLAIMS_FOR_ACCURACY_SCORE configurability', () => { |
| 490 | + async function buildServiceWithMinClaims(minClaims: number): Promise<SybilResistanceService> { |
| 491 | + const mod = await Test.createTestingModule({ |
| 492 | + providers: [ |
| 493 | + SybilResistanceService, |
| 494 | + { |
| 495 | + provide: PrismaService, |
| 496 | + useValue: { |
| 497 | + user: { findUnique: jest.fn(), findMany: jest.fn(), update: jest.fn() }, |
| 498 | + sybilScore: { create: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() }, |
| 499 | + }, |
| 500 | + }, |
| 501 | + { |
| 502 | + provide: ConfigService, |
| 503 | + useValue: { |
| 504 | + get: (key: string, defaultValue?: any) => |
| 505 | + key === 'sybil.minClaimsForAccuracyScore' ? minClaims : defaultValue, |
| 506 | + }, |
| 507 | + }, |
| 508 | + ], |
| 509 | + }).compile(); |
| 510 | + return mod.get<SybilResistanceService>(SybilResistanceService); |
| 511 | + } |
| 512 | + |
| 513 | + it('should use the default threshold of 5 when env var is not overridden', () => { |
| 514 | + // ConfigService mock returns default (5) — accuracy score is 0 for < 5 claims |
| 515 | + jest.spyOn(prisma.user, 'findUnique').mockResolvedValue({ |
| 516 | + ...mockUser, |
| 517 | + worldcoinVerified: false, |
| 518 | + wallets: [], |
| 519 | + }); |
| 520 | + // Access private field via any cast to verify initialization |
| 521 | + expect((service as any).MIN_CLAIMS_FOR_ACCURACY_SCORE).toBe(5); |
| 522 | + }); |
| 523 | + |
| 524 | + it('should read MIN_CLAIMS_FOR_ACCURACY_SCORE from ConfigService on construction', async () => { |
| 525 | + const customService = await buildServiceWithMinClaims(10); |
| 526 | + expect((customService as any).MIN_CLAIMS_FOR_ACCURACY_SCORE).toBe(10); |
| 527 | + }); |
| 528 | + |
| 529 | + it('should not award accuracy score when claims voted on is below configured threshold', async () => { |
| 530 | + const customService = await buildServiceWithMinClaims(10); |
| 531 | + const prismaInCustom = (customService as any).prisma; |
| 532 | + |
| 533 | + // Provide a user whose claimsVotedOn would be below threshold |
| 534 | + jest.spyOn(prismaInCustom.user, 'findUnique').mockResolvedValue({ |
| 535 | + ...mockUser, |
| 536 | + wallets: [], |
| 537 | + }); |
| 538 | + |
| 539 | + const { details } = await customService.computeSybilScore(mockUserId); |
| 540 | + expect(details.componentScores.accuracy).toBe(0); |
| 541 | + }); |
| 542 | + |
| 543 | + it('should award accuracy score when claims voted on meets custom threshold', async () => { |
| 544 | + // Use threshold of 3 and manually inject enough claims via gatherSignals override |
| 545 | + const customService = await buildServiceWithMinClaims(3); |
| 546 | + const prismaInCustom = (customService as any).prisma; |
| 547 | + |
| 548 | + jest.spyOn(prismaInCustom.user, 'findUnique').mockResolvedValue({ |
| 549 | + ...mockUser, |
| 550 | + wallets: [], |
| 551 | + }); |
| 552 | + |
| 553 | + // Spy on private gatherSignals to inject 4 correct out of 4 votes (above threshold 3) |
| 554 | + jest.spyOn(customService as any, 'gatherSignals').mockResolvedValue({ |
| 555 | + worldcoinVerified: false, |
| 556 | + oldestWalletAgeMs: 0, |
| 557 | + totalStakedAmount: BigInt(0), |
| 558 | + claimsVotedOn: 4, |
| 559 | + claimsCorrect: 4, |
| 560 | + }); |
| 561 | + |
| 562 | + const { details } = await customService.computeSybilScore(mockUserId); |
| 563 | + expect(details.componentScores.accuracy).toBe(1); |
| 564 | + }); |
| 565 | + |
| 566 | + it('should treat boundary value (exactly equal to threshold) as meeting the threshold', async () => { |
| 567 | + const customService = await buildServiceWithMinClaims(3); |
| 568 | + const prismaInCustom = (customService as any).prisma; |
| 569 | + |
| 570 | + jest.spyOn(prismaInCustom.user, 'findUnique').mockResolvedValue({ |
| 571 | + ...mockUser, |
| 572 | + wallets: [], |
| 573 | + }); |
| 574 | + |
| 575 | + jest.spyOn(customService as any, 'gatherSignals').mockResolvedValue({ |
| 576 | + worldcoinVerified: false, |
| 577 | + oldestWalletAgeMs: 0, |
| 578 | + totalStakedAmount: BigInt(0), |
| 579 | + claimsVotedOn: 3, // exactly at threshold |
| 580 | + claimsCorrect: 3, |
| 581 | + }); |
| 582 | + |
| 583 | + const { details } = await customService.computeSybilScore(mockUserId); |
| 584 | + expect(details.componentScores.accuracy).toBe(1); |
| 585 | + }); |
| 586 | + }); |
| 587 | + |
477 | 588 | describe('Edge cases', () => { |
478 | 589 | it('should handle users with no wallets', async () => { |
479 | 590 | const userNoWallets = { |
|
0 commit comments