import { Test, TestingModule } from "@nestjs/testing"; import { NotFoundException, ConflictException, ForbiddenException, BadRequestException } from "@nestjs/common"; import { NetworkService } from "./network.service"; import { PrismaService } from "../prisma/prisma.service"; import { MeilisearchService } from "./meilisearch.service"; import { ReputationEngine } from "./reputation.service"; import { GraphService } from "./graph.service"; import { AuthorizationService } from "../common/authorization.service"; import { RedisRateLimiterService } from "../common/redis-rate-limiter.service"; import { getQueueToken } from "@nestjs/bullmq"; describe("NetworkService", () => { let service: NetworkService; const mockPrisma = { guestPublicProfile: { findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), count: jest.fn(), }, networkBookingRequest: { findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), count: jest.fn(), }, networkBookmark: { upsert: jest.fn(), updateMany: jest.fn(), }, hostReview: { findUnique: jest.fn(), create: jest.fn(), }, profileView: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), count: jest.fn(), groupBy: jest.fn(), }, expertiseTag: { deleteMany: jest.fn(), createMany: jest.fn(), }, availabilityWindow: { deleteMany: jest.fn(), createMany: jest.fn(), }, guest: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), }, episode: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), }, booking: { findFirst: jest.fn(), create: jest.fn(), }, bookingHistory: { findFirst: jest.fn(), create: jest.fn(), }, guestJourneyEvent: { create: jest.fn(), }, user: { findUnique: jest.fn(), }, podcast: { findUnique: jest.fn(), }, $transaction: jest.fn(), $queryRaw: jest.fn(), }; mockPrisma.$transaction.mockImplementation((cb: (tx: typeof mockPrisma) => Promise): Promise => cb(mockPrisma)); const mockMeilisearch = { indexProfile: jest.fn(), removeProfile: jest.fn(), updateProfile: jest.fn(), searchProfiles: jest.fn(), }; const mockReputationEngine = { calculate: jest.fn(), getInsights: jest.fn(), }; const mockGraphService = { ingestProfile: jest.fn(), getRecommendations: jest.fn(), getNetworkStats: jest.fn(), }; const mockQueue = { add: jest.fn(), }; const mockAuthorizationService = { checkPodcastAccess: jest.fn().mockResolvedValue(undefined), checkAdminAccess: jest.fn().mockResolvedValue(undefined), }; const mockWorkspaceMember = { findFirst: jest.fn().mockResolvedValue({ id: "mock-wm-id" }), }; const mockPrismaWithWorkspaceMember = { ...mockPrisma, workspaceMember: mockWorkspaceMember, }; const mockRateLimiter = { checkLimit: jest.fn().mockResolvedValue({ allowed: true, remaining: 9, resetAt: new Date() }), }; beforeEach(async () => { // NOTE: the service now depends on both `mockPrisma` and the dedicated // `mockWorkspaceMember` double, so reset all of them explicitly (not just // `mockPrisma`) so every test starts from a clean mock surface. const module: TestingModule = await Test.createTestingModule({ providers: [ NetworkService, { provide: PrismaService, useValue: mockPrismaWithWorkspaceMember }, { provide: AuthorizationService, useValue: mockAuthorizationService }, { provide: RedisRateLimiterService, useValue: mockRateLimiter }, { provide: MeilisearchService, useValue: mockMeilisearch }, { provide: ReputationEngine, useValue: mockReputationEngine }, { provide: GraphService, useValue: mockGraphService }, { provide: getQueueToken("network"), useValue: mockQueue }, ], }).compile(); service = module.get(NetworkService); jest.clearAllMocks(); mockRateLimiter.checkLimit.mockResolvedValue({ allowed: true, remaining: 9, resetAt: new Date() }); }); afterEach(() => { jest.restoreAllMocks(); }); // ─── Outbound Request Rate Limiting ────────────────── describe("createBookingRequest — outbound rate limiting", () => { it("should allow a request when the user is under the hourly cap", async () => { mockRateLimiter.checkLimit.mockResolvedValue({ allowed: true, remaining: 9, resetAt: new Date() }); mockPrisma.guestPublicProfile.findUnique.mockResolvedValue({ id: "profile-1", isPublic: true, verificationStatus: "verified", userId: "user-other", user: { email: "guest@test.com" }, }); mockPrisma.podcast.findUnique.mockResolvedValue({ workspace: { members: [{ userId: "user-self" }] }, }); mockPrisma.networkBookingRequest.count.mockResolvedValue(0); mockPrisma.networkBookingRequest.create.mockResolvedValue({ id: "req-new", toProfile: { headline: "Guest", visibilitySettings: {}, phoneNumber: null, whatsappNumber: null, user: { email: "guest@test.com" } }, fromPodcast: { title: "Podcast" }, }); await expect( service.createBookingRequest({ toProfileId: "profile-1", fromPodcastId: "podcast-1", message: "Let's chat", proposedTopics: [], proposedDates: [], }, "test-user") ).resolves.toHaveProperty("id", "req-new"); expect(mockRateLimiter.checkLimit).toHaveBeenCalledWith("user:test-user:booking-request", { max: 20, windowMs: 3_600_000, keyPrefix: "network:booking", }); }); it("should reject a request when the user has exhausted the hourly cap", async () => { mockRateLimiter.checkLimit.mockResolvedValue({ allowed: false, remaining: 0, resetAt: new Date() }); await expect( service.createBookingRequest({ toProfileId: "profile-1", fromPodcastId: "podcast-1", message: "Let's chat", proposedTopics: [], proposedDates: [], }, "test-user") ).rejects.toThrow(BadRequestException); }); }); // ─── Self-Booking Prevention ───────────────────────── describe("createBookingRequest — self-booking prevention", () => { it("should reject booking request to own profile", async () => { mockPrisma.guestPublicProfile.findUnique.mockResolvedValue({ id: "profile-1", isPublic: true, verificationStatus: "verified", userId: "user-own", user: { email: "guest@test.com" }, }); mockPrisma.podcast.findUnique.mockResolvedValue({ workspace: { members: [{ userId: "user-own" }], }, }); await expect( service.createBookingRequest({ toProfileId: "profile-1", fromPodcastId: "podcast-1", message: "This is a test message that is long enough", proposedTopics: [], proposedDates: [], }, "test-user-123") ).rejects.toThrow(ForbiddenException); }); it("should reject duplicate pending requests", async () => { mockPrisma.guestPublicProfile.findUnique.mockResolvedValue({ id: "profile-1", isPublic: true, verificationStatus: "verified", userId: "user-other", user: { email: "guest@test.com" }, }); mockPrisma.podcast.findUnique.mockResolvedValue({ workspace: { members: [{ userId: "user-self" }], }, }); // Already 3 pending requests mockPrisma.networkBookingRequest.count.mockResolvedValue(3); await expect( service.createBookingRequest({ toProfileId: "profile-1", fromPodcastId: "podcast-1", message: "This is a test message that is long enough", proposedTopics: [], proposedDates: [], }, "test-user-123") ).rejects.toThrow(ConflictException); }); it("should reject proposed dates in the past", async () => { mockPrisma.guestPublicProfile.findUnique.mockResolvedValue({ id: "profile-1", isPublic: true, verificationStatus: "verified", userId: "user-other", user: { email: "guest@test.com" }, }); mockPrisma.podcast.findUnique.mockResolvedValue({ workspace: { members: [{ userId: "user-self" }], }, }); mockPrisma.networkBookingRequest.count.mockResolvedValue(0); const pastDate = new Date(Date.now() - 86400000).toISOString(); await expect( service.createBookingRequest({ toProfileId: "profile-1", fromPodcastId: "podcast-1", message: "This is a test message that is long enough", proposedTopics: [], proposedDates: [pastDate], }, "test-user-123") ).rejects.toThrow(BadRequestException); }); }); // ─── Booking Request Response ──────────────────────── describe("respondToBookingRequest", () => { it("should reject response to expired request (older than 30 days)", async () => { const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); mockPrisma.networkBookingRequest.findUnique.mockResolvedValue({ id: "req-1", status: "pending", createdAt: thirtyOneDaysAgo, toProfile: { userId: "user-1" }, fromPodcast: { id: "podcast-1", title: "Test Podcast", workspaceId: "ws-1" }, }); mockPrisma.networkBookingRequest.update.mockResolvedValue({ id: "req-1", status: "expired", }); await expect( service.respondToBookingRequest("req-1", "user-1", "accepted") ).rejects.toThrow(ConflictException); // Verify it was expired in the DB expect(mockPrisma.networkBookingRequest.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "expired" }), }) ); }); it("should reject response from wrong user", async () => { mockPrisma.networkBookingRequest.findUnique.mockResolvedValue({ id: "req-1", status: "pending", createdAt: new Date(), toProfile: { userId: "profile-owner" }, fromPodcast: { id: "podcast-1", title: "Test Podcast", workspaceId: "ws-1" }, }); await expect( service.respondToBookingRequest("req-1", "wrong-user", "accepted") ).rejects.toThrow(ForbiddenException); }); it("should reject response to already-responded request", async () => { mockPrisma.networkBookingRequest.findUnique.mockResolvedValue({ id: "req-1", status: "accepted", createdAt: new Date(), toProfile: { userId: "user-1" }, fromPodcast: { id: "podcast-1", title: "Test Podcast", workspaceId: "ws-1" }, }); await expect( service.respondToBookingRequest("req-1", "user-1", "declined") ).rejects.toThrow(ConflictException); }); }); // ─── Host Review Authorization ─────────────────────── describe("submitReview — host authorization", () => { it("should reject a review when the caller is not a member of the host podcast", async () => { mockWorkspaceMember.findFirst.mockResolvedValue(null); mockPrisma.networkBookingRequest.findUnique.mockResolvedValue({ id: "req-1", status: "accepted", toProfileId: "profile-1", fromPodcast: { id: "podcast-1", workspaceId: "ws-1" }, }); mockPrisma.hostReview.findUnique.mockResolvedValue(null); await expect( service.submitReview("tester", { bookingRequestId: "req-1", profileId: "profile-1", contentRating: 5, prepQuality: 4, overallRating: 5, comment: "Great guest!", }) ).rejects.toThrow(ForbiddenException); }); it("should allow a review when the caller is a member of the host podcast", async () => { mockWorkspaceMember.findFirst.mockResolvedValue({ id: "wm-1" }); mockPrisma.networkBookingRequest.findUnique.mockResolvedValue({ id: "req-1", status: "accepted", toProfileId: "profile-1", fromPodcast: { id: "podcast-1", workspaceId: "ws-1" }, }); mockPrisma.hostReview.findUnique.mockResolvedValue(null); mockPrisma.hostReview.create.mockResolvedValue({ id: "review-1", bookingRequestId: "req-1", profileId: "profile-1", }); const result = await service.submitReview("tester", { bookingRequestId: "req-1", profileId: "profile-1", contentRating: 5, prepQuality: 4, overallRating: 5, comment: "Great guest!", }); expect(result.id).toBe("review-1"); expect(mockPrisma.hostReview.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ bookingRequestId: "req-1", profileId: "profile-1", }), }) ); }); }); // ─── Duplicate Review Prevention ───────────────────── describe("submitReview — duplicate prevention", () => { it("should reject duplicate review for the same booking", async () => { mockPrisma.networkBookingRequest.findUnique.mockResolvedValue({ id: "req-1", status: "accepted", toProfileId: "profile-1", }); mockPrisma.hostReview.findUnique.mockResolvedValue({ id: "existing-review", bookingRequestId: "req-1", }); await expect( service.submitReview("test-user", { bookingRequestId: "req-1", profileId: "profile-1", contentRating: 5, prepQuality: 4, overallRating: 5, comment: "Great guest!", }) ).rejects.toThrow(ConflictException); }); it("should reject review with mismatched profileId", async () => { mockPrisma.networkBookingRequest.findUnique.mockResolvedValue({ id: "req-1", status: "accepted", toProfileId: "profile-correct", }); mockPrisma.hostReview.findUnique.mockResolvedValue(null); await expect( service.submitReview("test-user", { bookingRequestId: "req-1", profileId: "profile-wrong", contentRating: 5, prepQuality: 4, overallRating: 5, comment: "Great guest!", }) ).rejects.toThrow(BadRequestException); }); }); // ─── Profile View Rate Limiting ────────────────────── describe("trackView — rate limiting", () => { it("should deduplicate views from same podcast within 24h", async () => { mockRateLimiter.checkLimit.mockResolvedValue({ allowed: true, remaining: 9, resetAt: new Date() }); mockPrisma.profileView.findFirst.mockResolvedValue({ id: "view-1", profileId: "profile-1", viewerPodcastId: "podcast-1", }); mockPrisma.profileView.update.mockResolvedValue({ id: "view-1", }); const result = await service.trackView({ profileId: "profile-1", viewerPodcastId: "podcast-1", source: "search" as any, }); expect(result).toHaveProperty("deduplicated", true); // Should NOT create a new view expect(mockPrisma.profileView.create).not.toHaveBeenCalled(); }); }); });