Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions BackendAcademy/src/social/dto/follow.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
38 changes: 38 additions & 0 deletions BackendAcademy/src/social/follow.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
});
17 changes: 14 additions & 3 deletions BackendAcademy/src/social/follow.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { FollowDto } from './dto/follow.dto';
import {
DEFAULT_RANK_WEIGHT,
FeedItem,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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;

Expand Down