MicProtocol / apps /api /src /network /network.service.spec.ts
Benaah's picture
Upload folder using huggingface_hub
6abfd9d verified
Raw History Blame Contribute Delete
16 kB
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(<T>(cb: (tx: typeof mockPrisma) => Promise<T>): Promise<T> => 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>(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();
});
});
});