From a5410fb218e26838fd23975ff89716d35c212773 Mon Sep 17 00:00:00 2001 From: Ranjeet Prabhat Date: Wed, 14 Oct 2026 07:34:09 +0545 Subject: [PATCH] feat(social): make follow operations idempotent (BA-052) --- BackendAcademy/src/social/dto/follow.dto.ts | 3 ++ .../src/social/follow.service.spec.ts | 38 +++++++++++++++++++ BackendAcademy/src/social/follow.service.ts | 17 +++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/BackendAcademy/src/social/dto/follow.dto.ts b/BackendAcademy/src/social/dto/follow.dto.ts index 401d080256..e3753d37b7 100644 --- a/BackendAcademy/src/social/dto/follow.dto.ts +++ b/BackendAcademy/src/social/dto/follow.dto.ts @@ -1,10 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class FollowDto { + @ApiProperty({ example: 'learner-1', description: 'The user initiating the follow' }) @IsString() @IsNotEmpty() followerId: string; + @ApiProperty({ example: 'tutor-1', description: 'The user being followed' }) @IsString() @IsNotEmpty() followeeId: string; diff --git a/BackendAcademy/src/social/follow.service.spec.ts b/BackendAcademy/src/social/follow.service.spec.ts index 2a3357c857..9ff37ed996 100644 --- a/BackendAcademy/src/social/follow.service.spec.ts +++ b/BackendAcademy/src/social/follow.service.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; +import { FollowDto } from './dto/follow.dto'; import { FollowService } from './follow.service'; import { DEFAULT_RANK_WEIGHT, FOLLOW_RANK_WEIGHT } from './social.types'; @@ -152,4 +153,41 @@ describe('FollowService', () => { expect(service.getFeed('nobody')).toEqual([]); }); }); + + // ── BA-052: Make follow operations idempotent ─────────────────────────── + + describe('BA-052 — Idempotent follow operations', () => { + it('does not create duplicate relationships on repeated follow requests', () => { + const e1 = service.follow('user-a', 'user-b'); + const e2 = service.follow('user-a', 'user-b'); + const e3 = service.follow('user-a', 'user-b'); + + expect(e2).toEqual(e1); + expect(e3).toEqual(e1); + + // Follower / following collections must have exactly 1 edge + expect(service.getFollowing('user-a')).toEqual(['user-b']); + expect(service.getFollowers('user-b')).toEqual(['user-a']); + expect(service.getCounts('user-b')).toEqual({ followers: 1, following: 0 }); + expect(service.getCounts('user-a')).toEqual({ followers: 0, following: 1 }); + }); + + it('supports DTO invocation idempotently without duplicating edges', () => { + const dto: FollowDto = { followerId: 'learner-10', followeeId: 'tutor-20' }; + const e1 = service.follow(dto); + const e2 = service.follow(dto); + + expect(e1).toEqual(e2); + expect(service.isFollowing('learner-10', 'tutor-20')).toBe(true); + expect(service.getFollowing('learner-10')).toHaveLength(1); + }); + + it('unfollow is also idempotent when called repeatedly', () => { + service.follow('user-x', 'user-y'); + expect(service.unfollow('user-x', 'user-y')).toBe(true); + expect(service.unfollow('user-x', 'user-y')).toBe(false); + expect(service.unfollow({ followerId: 'user-x', followeeId: 'user-y' })).toBe(false); + expect(service.isFollowing('user-x', 'user-y')).toBe(false); + }); + }); }); diff --git a/BackendAcademy/src/social/follow.service.ts b/BackendAcademy/src/social/follow.service.ts index ed3f8332b3..f6376219e3 100644 --- a/BackendAcademy/src/social/follow.service.ts +++ b/BackendAcademy/src/social/follow.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { FollowDto } from './dto/follow.dto'; import { DEFAULT_RANK_WEIGHT, FeedItem, @@ -35,14 +36,19 @@ export class FollowService { return `${followerId}|${followeeId}`; } - follow(followerId: string, followeeId: string): FollowEdge { + follow(followerId: string, followeeId: string): FollowEdge; + follow(dto: FollowDto): FollowEdge; + follow(followerOrDto: string | FollowDto, maybeFolloweeId?: string): FollowEdge { + const followerId = typeof followerOrDto === 'object' ? followerOrDto.followerId : followerOrDto; + const followeeId = typeof followerOrDto === 'object' ? followerOrDto.followeeId : maybeFolloweeId!; + if (followerId === followeeId) { throw new BadRequestException('An account cannot follow itself'); } const key = FollowService.edgeKey(followerId, followeeId); const existing = this.edges.get(key); - // Idempotent: re-following must not duplicate the edge or reset followedAt. + // Idempotent: re-following must not duplicate the edge, reset followedAt, or create duplicate relationships. if (existing) return existing; const edge: FollowEdge = { @@ -59,8 +65,13 @@ export class FollowService { /** * Removes the edge. Deliberately does not touch `feeds`: items already fanned * out stay in the follower's history (BE-088). + * Operation is idempotent: repeated unfollow requests safely return false without side-effects. */ - unfollow(followerId: string, followeeId: string): boolean { + unfollow(followerId: string, followeeId: string): boolean; + unfollow(dto: FollowDto): boolean; + unfollow(followerOrDto: string | FollowDto, maybeFolloweeId?: string): boolean { + const followerId = typeof followerOrDto === 'object' ? followerOrDto.followerId : followerOrDto; + const followeeId = typeof followerOrDto === 'object' ? followerOrDto.followeeId : maybeFolloweeId!; const key = FollowService.edgeKey(followerId, followeeId); if (!this.edges.delete(key)) return false;