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
52 changes: 1 addition & 51 deletions src/adoption/adoption.controller.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,14 @@
import {
Controller,
Post,
Patch,
Param,
Body,
UseGuards,
Req,
HttpCode,
HttpStatus,
UseInterceptors,
BadRequestException,
UploadedFiles,
InternalServerErrorException,

} from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '../auth/enums/role.enum';
import { AdoptionService } from './adoption.service';
import { CreateAdoptionDto } from './dto/create-adoption.dto';
import { DocumentsService } from '../documents/documents.service';
import { FilesInterceptor } from '@nestjs/platform-express';
import { EventsService } from '../events/events.service';
import { EventEntityType, EventType } from '@prisma/client';

interface AuthRequest extends Request {
user: { userId: string; email: string; role: string; sub?: string };
}

@Controller('adoption')
@UseGuards(JwtAuthGuard)
export class AdoptionController {
constructor(
private readonly adoptionService: AdoptionService,
private readonly documentsService: DocumentsService,
private readonly eventsService: EventsService,
) {}

/**
* POST /adoption/requests
* Any authenticated user can request to adopt a pet.
* Fires ADOPTION_REQUESTED event on success.
*/
@Post('requests')
@HttpCode(HttpStatus.CREATED)
requestAdoption(@Req() req: AuthRequest, @Body() dto: CreateAdoptionDto) {
return this.adoptionService.requestAdoption(
(req.user.userId || req.user.sub) as string,
dto,
);
}

/**
* PATCH /adoption/:id/approve
* Admin-only. Approves a pending adoption request.
* Fires ADOPTION_APPROVED event on success.
*/
@Patch(':id/approve')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN)
approveAdoption(@Req() req: AuthRequest, @Param('id') id: string) {
return this.adoptionService.updateAdoptionStatus(id, req.user.userId, {
Expand Down
3 changes: 1 addition & 2 deletions src/adoption/adoption.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Module } from '@nestjs/common';
import { AdoptionController } from './adoption.controller';
import { AdoptionService } from './adoption.service';
import { PrismaModule } from '../prisma/prisma.module';
import { DocumentsModule } from '../documents/documents.module';


@Module({
imports: [PrismaModule, DocumentsModule],
Expand Down
203 changes: 1 addition & 202 deletions src/adoption/adoption.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ConflictException } from '@nestjs/common';
import { AdoptionService } from './adoption.service';
import { PrismaService } from '../prisma/prisma.service';
import { EventsService } from '../events/events.service';
import { EventType, EventEntityType, AdoptionStatus } from '@prisma/client';

const ADOPTER_ID = 'adopter-uuid';
const PET_ID = 'pet-uuid';
const OWNER_ID = 'owner-uuid';
const ADOPTION_ID = 'adoption-uuid';
const ACTOR_ID = 'admin-uuid';

const mockAdoption = {
id: ADOPTION_ID,
petId: PET_ID,
ownerId: OWNER_ID,
adopterId: ADOPTER_ID,
status: AdoptionStatus.REQUESTED,
notes: null,
escrowId: null,
createdAt: new Date(),
updatedAt: new Date(),
};

describe('AdoptionService', () => {
let service: AdoptionService;

const mockPrisma = {
pet: { findUnique: jest.fn() },
user: { findUnique: jest.fn() },
adoption: {
create: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn().mockImplementation(async (cb) => cb(mockPrisma)),
};

const mockEvents = {
logEvent: jest.fn(),
};

beforeEach(async () => {
Expand All @@ -48,178 +12,13 @@ describe('AdoptionService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdoptionService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: EventsService, useValue: mockEvents },

],
}).compile();

service = module.get<AdoptionService>(AdoptionService);
});

// ─── requestAdoption ──────────────────────────────────

describe('requestAdoption', () => {
const dto = { petId: PET_ID, ownerId: OWNER_ID };

it('creates the adoption record and fires ADOPTION_REQUESTED', async () => {
mockPrisma.pet.findUnique.mockResolvedValue({ id: PET_ID, currentOwnerId: OWNER_ID });
mockPrisma.adoption.findFirst.mockResolvedValue(null);
mockPrisma.adoption.create.mockResolvedValue(mockAdoption);
mockEvents.logEvent.mockResolvedValue({});

const result = await service.requestAdoption(ADOPTER_ID, dto);

expect(mockPrisma.adoption.create).toHaveBeenCalledWith({
data: {
petId: PET_ID,
ownerId: OWNER_ID,
adopterId: ADOPTER_ID,
notes: undefined,
status: AdoptionStatus.REQUESTED,
},
});

expect(mockEvents.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
entityType: EventEntityType.ADOPTION,
entityId: ADOPTION_ID,
eventType: EventType.ADOPTION_REQUESTED,
actorId: ADOPTER_ID,
}),
);

expect(result).toEqual(mockAdoption);
});

it('throws NotFoundException when the pet does not exist', async () => {
mockPrisma.pet.findUnique.mockResolvedValue(null);

await expect(service.requestAdoption(ADOPTER_ID, dto)).rejects.toThrow(
NotFoundException,
);

expect(mockPrisma.adoption.create).not.toHaveBeenCalled();
expect(mockEvents.logEvent).not.toHaveBeenCalled();
});

it('throws ConflictException when the pet has no owner assigned', async () => {
mockPrisma.pet.findUnique.mockResolvedValue({ id: PET_ID, currentOwnerId: null });

await expect(service.requestAdoption(ADOPTER_ID, dto)).rejects.toThrow(
ConflictException,
);

expect(mockPrisma.adoption.create).not.toHaveBeenCalled();
});

it('throws ConflictException when there is an active adoption', async () => {
mockPrisma.pet.findUnique.mockResolvedValue({ id: PET_ID, currentOwnerId: OWNER_ID });
mockPrisma.adoption.findFirst.mockResolvedValue(mockAdoption);

await expect(service.requestAdoption(ADOPTER_ID, dto)).rejects.toThrow(
ConflictException,
);

expect(mockPrisma.adoption.create).not.toHaveBeenCalled();
});

it('propagates logEvent errors (no silent failure)', async () => {
mockPrisma.pet.findUnique.mockResolvedValue({ id: PET_ID, currentOwnerId: OWNER_ID });
mockPrisma.adoption.findFirst.mockResolvedValue(null);
mockPrisma.adoption.create.mockResolvedValue(mockAdoption);
mockEvents.logEvent.mockRejectedValue(new Error('DB connection lost'));

await expect(service.requestAdoption(ADOPTER_ID, dto)).rejects.toThrow(
'DB connection lost',
);
});
});

// ─── updateAdoptionStatus ─────────────────────────────

describe('updateAdoptionStatus', () => {
it('updates status to APPROVED and fires ADOPTION_APPROVED', async () => {
const updated = { ...mockAdoption, status: AdoptionStatus.APPROVED };
mockPrisma.adoption.findUnique.mockResolvedValue(mockAdoption);
mockPrisma.adoption.update.mockResolvedValue(updated);
mockEvents.logEvent.mockResolvedValue({});

const result = await service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, {
status: 'APPROVED',
});

expect(mockPrisma.adoption.update).toHaveBeenCalledWith({
where: { id: ADOPTION_ID },
data: { status: 'APPROVED' },
});

expect(mockEvents.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: EventType.ADOPTION_APPROVED,
entityId: ADOPTION_ID,
actorId: ACTOR_ID,
}),
);

expect(result.status).toBe(AdoptionStatus.APPROVED);
});

it('updates status to COMPLETED and fires ADOPTION_COMPLETED', async () => {
const updated = { ...mockAdoption, status: AdoptionStatus.COMPLETED };
mockPrisma.adoption.findUnique.mockResolvedValue(mockAdoption);
mockPrisma.adoption.update.mockResolvedValue(updated);
mockEvents.logEvent.mockResolvedValue({});

await service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, {
status: 'COMPLETED',
});

expect(mockEvents.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: EventType.ADOPTION_COMPLETED,
}),
);
});

it('updates status to REJECTED without firing an event', async () => {
const updated = { ...mockAdoption, status: AdoptionStatus.REJECTED };
mockPrisma.adoption.findUnique.mockResolvedValue(mockAdoption);
mockPrisma.adoption.update.mockResolvedValue(updated);

await service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, {
status: 'REJECTED',
});

// REJECTED has no mapped EventType — logEvent should NOT be called
expect(mockEvents.logEvent).not.toHaveBeenCalled();
});

it('throws NotFoundException when adoption does not exist', async () => {
mockPrisma.adoption.findUnique.mockResolvedValue(null);

await expect(
service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, {
status: 'APPROVED',
}),
).rejects.toThrow(NotFoundException);

expect(mockPrisma.adoption.update).not.toHaveBeenCalled();
expect(mockEvents.logEvent).not.toHaveBeenCalled();
});

it('propagates logEvent errors (no silent failure)', async () => {
const updated = { ...mockAdoption, status: AdoptionStatus.APPROVED };
mockPrisma.adoption.findUnique.mockResolvedValue(mockAdoption);
mockPrisma.adoption.update.mockResolvedValue(updated);
mockEvents.logEvent.mockRejectedValue(
new Error('Event store unavailable'),
);

await expect(
service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, {
status: 'APPROVED',
}),
).rejects.toThrow('Event store unavailable');
});
});
});
Loading