From 0dfb7c7402075a134d4671aa94d665cd0e45b40d Mon Sep 17 00:00:00 2001 From: KingFRANKHOOD Date: Tue, 25 Aug 2026 15:36:41 +0100 Subject: [PATCH] feat: add follow endpoint with integration tests Add follow/unfollow endpoints for creators with idempotent behavior and follower count tracking. Changes: - Add Follow Prisma model with unique constraint on (followerAddress, creatorId) - Add followersCount field to CreatorProfile model - Create follow service with idempotent follow/unfollow logic - Create follow handlers for POST and DELETE endpoints - Add follow routes to creator router - Add comprehensive integration tests covering: - Follow increments count by 1 - Double follow is idempotent (count not incremented twice) - Unfollow decrements count by 1 - Double unfollow is idempotent (count does not go below 0) - Unauthenticated follow returns 401 - Non-existent creator returns 404 --- prisma/schema/creator.prisma | 32 +-- prisma/schema/follow.prisma | 14 + src/modules/creator/creator.routes.ts | 28 ++ src/modules/creator/follow.handlers.ts | 99 ++++++++ .../creator/follow.integration.test.ts | 239 ++++++++++++++++++ src/modules/creator/follow.service.ts | 129 ++++++++++ 6 files changed, 526 insertions(+), 15 deletions(-) create mode 100644 prisma/schema/follow.prisma create mode 100644 src/modules/creator/follow.handlers.ts create mode 100644 src/modules/creator/follow.integration.test.ts create mode 100644 src/modules/creator/follow.service.ts diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index 3c732132..2142251b 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -1,22 +1,24 @@ // prisma/schema/creator.prisma model CreatorProfile { - id String @id @default(cuid()) - userId String @unique - handle String @unique - displayName String - bio String? - avatarUrl String? - perkSummary String? - isVerified Boolean @default(false) - perks Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String @unique + handle String @unique + displayName String + bio String? + avatarUrl String? + perkSummary String? + isVerified Boolean @default(false) + perks Json? + followersCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - priceSnapshot CreatorPriceSnapshot? - priceHistory CreatorPriceHistory[] - posts CreatorPost[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + priceSnapshot CreatorPriceSnapshot? + priceHistory CreatorPriceHistory[] + posts CreatorPost[] + followers Follow[] } model CreatorPost { diff --git a/prisma/schema/follow.prisma b/prisma/schema/follow.prisma new file mode 100644 index 00000000..f05ad9fe --- /dev/null +++ b/prisma/schema/follow.prisma @@ -0,0 +1,14 @@ +// prisma/schema/follow.prisma + +model Follow { + id String @id @default(cuid()) + followerAddress String + creatorId String + createdAt DateTime @default(now()) + + creator CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + @@unique([followerAddress, creatorId]) + @@index([creatorId]) + @@index([followerAddress]) +} diff --git a/src/modules/creator/creator.routes.ts b/src/modules/creator/creator.routes.ts index 1be37d46..d3705866 100644 --- a/src/modules/creator/creator.routes.ts +++ b/src/modules/creator/creator.routes.ts @@ -5,6 +5,10 @@ import { getCreatorProfileHandler, upsertCreatorProfileHandler, } from './creator-profile.handlers'; +import { + httpFollowCreator, + httpUnfollowCreator, +} from './follow.handlers'; import { ROOT as CREATORS_ROOT } from '../../constants/creator.constants'; import { cacheControl } from '../../middlewares/cache-control.middleware'; import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants'; @@ -74,4 +78,28 @@ router.all('/:creatorId/profile', (_req, res) => { res.set('Allow', 'GET, PUT').sendStatus(405); }); +/** + * @route POST /api/v1/creators/:creatorId/follow + * @desc Follow a creator (idempotent) + * @access Requires Stellar signature verification + */ +router.post( + '/:creatorId/follow', + validateCreatorParam('creatorId'), + requireStellarSignature(), + httpFollowCreator +); + +/** + * @route DELETE /api/v1/creators/:creatorId/follow + * @desc Unfollow a creator (idempotent) + * @access Requires Stellar signature verification + */ +router.delete( + '/:creatorId/follow', + validateCreatorParam('creatorId'), + requireStellarSignature(), + httpUnfollowCreator +); + export default router; diff --git a/src/modules/creator/follow.handlers.ts b/src/modules/creator/follow.handlers.ts new file mode 100644 index 00000000..c1f891df --- /dev/null +++ b/src/modules/creator/follow.handlers.ts @@ -0,0 +1,99 @@ +import { Request, Response } from 'express'; +import { logger } from '../../utils/logger.utils'; +import { + sendSuccess, + sendError, + sendNotFound, +} from '../../utils/api-response.utils'; +import { ErrorCode } from '../../constants/error.constants'; +import { followCreator, unfollowCreator } from './follow.service'; +import { creatorProfileExists } from './creator-profile.service'; + +export async function httpFollowCreator( + req: Request<{ creatorId: string }>, + res: Response +): Promise { + try { + const creatorId = String(req.params.creatorId); + const walletAddress = (req as any).walletAddress; + + if (!walletAddress) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Wallet address is required' + ); + return; + } + + const exists = await creatorProfileExists(creatorId); + if (!exists) { + sendNotFound(res, 'Creator'); + return; + } + + const result = await followCreator(creatorId, walletAddress); + const statusCode = result.action === 'followed' ? 201 : 200; + sendSuccess(res, result, statusCode); + } catch (error) { + logger.error( + { + type: 'follow_handler_error', + handler: 'httpFollowCreator', + error, + }, + 'Error following creator' + ); + sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to follow creator' + ); + } +} + +export async function httpUnfollowCreator( + req: Request<{ creatorId: string }>, + res: Response +): Promise { + try { + const creatorId = String(req.params.creatorId); + const walletAddress = (req as any).walletAddress; + + if (!walletAddress) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Wallet address is required' + ); + return; + } + + const exists = await creatorProfileExists(creatorId); + if (!exists) { + sendNotFound(res, 'Creator'); + return; + } + + const result = await unfollowCreator(creatorId, walletAddress); + sendSuccess(res, result, 200); + } catch (error) { + logger.error( + { + type: 'unfollow_handler_error', + handler: 'httpUnfollowCreator', + error, + }, + 'Error unfollowing creator' + ); + sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to unfollow creator' + ); + } +} diff --git a/src/modules/creator/follow.integration.test.ts b/src/modules/creator/follow.integration.test.ts new file mode 100644 index 00000000..aa039737 --- /dev/null +++ b/src/modules/creator/follow.integration.test.ts @@ -0,0 +1,239 @@ +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + follow: { + findUnique: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + creatorProfile: { + findUnique: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn(), + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + isLevelEnabled: jest.fn().mockReturnValue(false), + }, +})); + +jest.mock('../../config', () => ({ + envConfig: { + MODE: 'test', + PORT: 3000, + ENABLE_REQUEST_LOGGING: false, + }, + appConfig: { allowedOrigins: [] }, +})); + +jest.mock('../../utils/wallet-ownership.utils', () => ({ + checkCreatorProfileOwnership: jest.fn(), +})); + +jest.mock('../../middlewares/stellar-signature.middleware', () => ({ + requireStellarSignature: + () => (req: any, _res: any, next: any) => { + req.walletAddress = + req.headers['x-wallet-address'] || + req.headers['wallet-address']; + req.signatureVerified = true; + next(); + }, +})); + +jest.mock('./creator-profile.service', () => ({ + getCreatorProfile: jest.fn(), + upsertCreatorProfile: jest.fn(), + creatorProfileExists: jest.fn(), +})); + +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; +import { creatorProfileExists } from './creator-profile.service'; + +const mockedPrisma = prisma as unknown as { + follow: { findUnique: jest.Mock; create: jest.Mock; delete: jest.Mock }; + creatorProfile: { findUnique: jest.Mock; update: jest.Mock }; + $transaction: jest.Mock; +}; +const mockedCreatorProfileExists = + creatorProfileExists as jest.MockedFunction; + +const CREATOR_ID = 'test-creator-follow-1'; +const FOLLOWER_ADDRESS = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + +describe('POST/DELETE /api/v1/creators/:creatorId/follow — follower count', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedCreatorProfileExists.mockResolvedValue(true); + }); + + it('increments follower count by 1 on follow', async () => { + mockedPrisma.follow.findUnique.mockResolvedValue(null); + mockedPrisma.$transaction.mockImplementation(async (fn: any) => { + const tx = { + follow: { + create: jest.fn().mockResolvedValue({ + id: 'follow-1', + followerAddress: FOLLOWER_ADDRESS, + creatorId: CREATOR_ID, + createdAt: new Date(), + }), + }, + creatorProfile: { + update: jest.fn().mockResolvedValue({ + id: CREATOR_ID, + followersCount: 1, + }), + }, + }; + await fn(tx); + return tx.creatorProfile.update.mock.results[0].value; + }); + + const res = await supertest(app) + .post(`/api/v1/creators/${CREATOR_ID}/follow`) + .set('x-wallet-address', FOLLOWER_ADDRESS); + + expect(res.status).toBe(201); + expect(res.body).toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + action: 'followed', + followersCount: 1, + }), + }) + ); + }); + + it('does not increment count on double follow (idempotent)', async () => { + mockedPrisma.follow.findUnique.mockResolvedValue({ + id: 'existing-follow', + followerAddress: FOLLOWER_ADDRESS, + creatorId: CREATOR_ID, + createdAt: new Date(), + }); + mockedPrisma.creatorProfile.findUnique.mockResolvedValue({ + id: CREATOR_ID, + followersCount: 1, + } as any); + + const res = await supertest(app) + .post(`/api/v1/creators/${CREATOR_ID}/follow`) + .set('x-wallet-address', FOLLOWER_ADDRESS); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + action: 'already_following', + followersCount: 1, + }), + }) + ); + }); + + it('decrements follower count by 1 on unfollow', async () => { + mockedPrisma.follow.findUnique.mockResolvedValue({ + id: 'existing-follow', + followerAddress: FOLLOWER_ADDRESS, + creatorId: CREATOR_ID, + createdAt: new Date(), + }); + mockedPrisma.$transaction.mockImplementation(async (fn: any) => { + const tx = { + follow: { + delete: jest.fn().mockResolvedValue({}), + }, + creatorProfile: { + update: jest.fn().mockResolvedValue({ + id: CREATOR_ID, + followersCount: 0, + }), + }, + }; + await fn(tx); + return tx.creatorProfile.update.mock.results[0].value; + }); + + const res = await supertest(app) + .delete(`/api/v1/creators/${CREATOR_ID}/follow`) + .set('x-wallet-address', FOLLOWER_ADDRESS); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + action: 'unfollowed', + followersCount: 0, + }), + }) + ); + }); + + it('does not decrement count below 0 on double unfollow (idempotent)', async () => { + mockedPrisma.follow.findUnique.mockResolvedValue(null); + mockedPrisma.creatorProfile.findUnique.mockResolvedValue({ + id: CREATOR_ID, + followersCount: 0, + } as any); + + const res = await supertest(app) + .delete(`/api/v1/creators/${CREATOR_ID}/follow`) + .set('x-wallet-address', FOLLOWER_ADDRESS); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + action: 'not_following', + followersCount: 0, + }), + }) + ); + }); + + it('returns 401 for unauthenticated follow request', async () => { + const res = await supertest(app) + .post(`/api/v1/creators/${CREATOR_ID}/follow`); + + expect(res.status).toBe(401); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'UNAUTHORIZED', + }), + }) + ); + }); + + it('returns 404 when creator does not exist', async () => { + mockedCreatorProfileExists.mockResolvedValue(false); + + const res = await supertest(app) + .post(`/api/v1/creators/nonexistent-creator/follow`) + .set('x-wallet-address', FOLLOWER_ADDRESS); + + expect(res.status).toBe(404); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'NOT_FOUND', + }), + }) + ); + }); +}); diff --git a/src/modules/creator/follow.service.ts b/src/modules/creator/follow.service.ts new file mode 100644 index 00000000..73233c8c --- /dev/null +++ b/src/modules/creator/follow.service.ts @@ -0,0 +1,129 @@ +import { prisma } from '../../utils/prisma.utils'; + +export interface FollowResult { + creatorId: string; + followerAddress: string; + action: 'followed' | 'already_following'; + followersCount: number; +} + +export interface UnfollowResult { + creatorId: string; + followerAddress: string; + action: 'unfollowed' | 'not_following'; + followersCount: number; +} + +export async function followCreator( + creatorId: string, + followerAddress: string +): Promise { + const existing = await prisma.follow.findUnique({ + where: { + followerAddress_creatorId: { + followerAddress, + creatorId, + }, + }, + }); + + if (existing) { + const profile = await prisma.creatorProfile.findUnique({ + where: { id: creatorId }, + select: { followersCount: true }, + }); + + return { + creatorId, + followerAddress, + action: 'already_following', + followersCount: profile?.followersCount ?? 0, + }; + } + + const result = await prisma.$transaction(async (tx) => { + await tx.follow.create({ + data: { + followerAddress, + creatorId, + }, + }); + + const updated = await tx.creatorProfile.update({ + where: { id: creatorId }, + data: { followersCount: { increment: 1 } }, + select: { followersCount: true }, + }); + + return updated; + }); + + return { + creatorId, + followerAddress, + action: 'followed', + followersCount: result.followersCount, + }; +} + +export async function unfollowCreator( + creatorId: string, + followerAddress: string +): Promise { + const existing = await prisma.follow.findUnique({ + where: { + followerAddress_creatorId: { + followerAddress, + creatorId, + }, + }, + }); + + if (!existing) { + const profile = await prisma.creatorProfile.findUnique({ + where: { id: creatorId }, + select: { followersCount: true }, + }); + + return { + creatorId, + followerAddress, + action: 'not_following', + followersCount: profile?.followersCount ?? 0, + }; + } + + const result = await prisma.$transaction(async (tx) => { + await tx.follow.delete({ + where: { + followerAddress_creatorId: { + followerAddress, + creatorId, + }, + }, + }); + + const updated = await tx.creatorProfile.update({ + where: { id: creatorId }, + data: { followersCount: { decrement: 1 } }, + select: { followersCount: true }, + }); + + return updated; + }); + + return { + creatorId, + followerAddress, + action: 'unfollowed', + followersCount: Math.max(0, result.followersCount), + }; +} + +export async function getFollowerCount(creatorId: string): Promise { + const profile = await prisma.creatorProfile.findUnique({ + where: { id: creatorId }, + select: { followersCount: true }, + }); + return profile?.followersCount ?? 0; +}