Skip to content

Commit e3fb806

Browse files
authored
Merge pull request #732 from NickiM84/fix/nickim84-issues-655-657-662-665
fix: bound chat storage (#655), decimal-safe wallet amounts (#657), raw-body webhook verification (#662), payments constructor integrity (#665)
2 parents ec209da + e2b0801 commit e3fb806

13 files changed

Lines changed: 1031 additions & 80 deletions

BackendAcademy/src/chat/chat.controller.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
1+
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
22
import { ChatService } from './chat.service';
33
import { CreateMessageDto } from './dto/create-message.dto';
44
import { CreateRoomDto } from './dto/create-room.dto';
55
import { ShareCodeSnippetDto } from './dto/share-code-snippet.dto';
6+
import { PageOptions } from './repositories/chat.repository.interface';
67

78
@Controller('chat')
89
export class ChatController {
@@ -14,8 +15,15 @@ export class ChatController {
1415
}
1516

1617
@Get('rooms')
17-
findAllRooms() {
18-
return this.chatService.findAllRooms();
18+
findAllRooms(
19+
@Query('limit') limit?: string,
20+
@Query('cursor') cursor?: string,
21+
) {
22+
const options: PageOptions = {
23+
limit: limit ? Number(limit) : undefined,
24+
cursor,
25+
};
26+
return this.chatService.findAllRooms(options);
1927
}
2028

2129
@Get('rooms/:roomId')
@@ -29,8 +37,16 @@ export class ChatController {
2937
}
3038

3139
@Get('rooms/:roomId/messages')
32-
findMessagesByRoom(@Param('roomId') roomId: string) {
33-
return this.chatService.findMessagesByRoom(roomId);
40+
findMessagesByRoom(
41+
@Param('roomId') roomId: string,
42+
@Query('limit') limit?: string,
43+
@Query('cursor') cursor?: string,
44+
) {
45+
const options: PageOptions = {
46+
limit: limit ? Number(limit) : undefined,
47+
cursor,
48+
};
49+
return this.chatService.findMessagesByRoom(roomId, options);
3450
}
3551

3652
@Post('messages/share-code')

BackendAcademy/src/chat/chat.service.spec.ts

Lines changed: 184 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import { HttpException, HttpStatus } from '@nestjs/common';
1+
import { ForbiddenException, HttpException, HttpStatus, NotFoundException } from '@nestjs/common';
22
import { ChatService } from './chat.service';
33
import {
44
ChatRateLimiter,
55
DEFAULT_CHAT_RATE_LIMIT,
66
} from './chat-rate-limit';
7+
import { InMemoryChatRepository } from './repositories/chat.repository.in-memory';
8+
9+
/** Creates a room and returns its id. */
10+
function createRoom(service: ChatService, participants: string[], name = 'room-1'): string {
11+
const room = service.createRoom({ name, type: 'room', participants });
12+
return room.id;
13+
}
714

815
describe('ChatRateLimiter', () => {
916
it('allows messages up to the configured limit then blocks', () => {
@@ -43,9 +50,10 @@ describe('ChatRateLimiter', () => {
4350
describe('ChatService rate limiting', () => {
4451
it('rejects messages once a session exceeds the limit', () => {
4552
const service = new ChatService();
53+
const roomId = createRoom(service, ['spammer']);
4654
const send = () =>
4755
service.createMessage({
48-
roomId: 'room-1',
56+
roomId,
4957
senderId: 'spammer',
5058
content: 'hi',
5159
});
@@ -66,17 +74,18 @@ describe('ChatService rate limiting', () => {
6674

6775
it('shares the limit across createMessage and shareCodeSnippet', () => {
6876
const service = new ChatService();
77+
const roomId = createRoom(service, ['user-1']);
6978
for (let i = 0; i < DEFAULT_CHAT_RATE_LIMIT.maxMessages; i++) {
7079
service.createMessage({
71-
roomId: 'room-1',
80+
roomId,
7281
senderId: 'user-1',
7382
content: 'hi',
7483
});
7584
}
7685

7786
expect(() =>
7887
service.shareCodeSnippet({
79-
roomId: 'room-1',
88+
roomId,
8089
senderId: 'user-1',
8190
content: 'snippet',
8291
code: 'fn main() {}',
@@ -89,9 +98,10 @@ describe('ChatService rate limiting', () => {
8998
describe('ChatService code snippet sharing', () => {
9099
it('creates a shared code snippet message with metadata', () => {
91100
const service = new ChatService();
101+
const roomId = createRoom(service, ['user-1']);
92102

93103
const result = service.shareCodeSnippet({
94-
roomId: 'room-1',
104+
roomId,
95105
senderId: 'user-1',
96106
content: 'Shared a Rust snippet',
97107
code: 'fn main() { println!("hi"); }',
@@ -100,7 +110,7 @@ describe('ChatService code snippet sharing', () => {
100110
});
101111

102112
expect(result).toMatchObject({
103-
roomId: 'room-1',
113+
roomId,
104114
senderId: 'user-1',
105115
content: 'Shared a Rust snippet',
106116
codeSnippet: {
@@ -110,12 +120,177 @@ describe('ChatService code snippet sharing', () => {
110120
},
111121
});
112122

113-
const roomMessages = service.findMessagesByRoom('room-1');
114-
expect(roomMessages).toHaveLength(1);
115-
expect(roomMessages[0].codeSnippet).toEqual({
123+
const roomMessages = service.findMessagesByRoom(roomId);
124+
expect(roomMessages.items).toHaveLength(1);
125+
expect(roomMessages.items[0].codeSnippet).toEqual({
116126
code: 'fn main() { println!("hi"); }',
117127
language: 'rust',
118128
title: 'Hello World',
119129
});
120130
});
121131
});
132+
133+
describe('ChatService authorization (#655)', () => {
134+
it('rejects messages for a room that does not exist', () => {
135+
const service = new ChatService();
136+
expect(() =>
137+
service.createMessage({
138+
roomId: 'missing-room',
139+
senderId: 'user-1',
140+
content: 'hi',
141+
}),
142+
).toThrow(NotFoundException);
143+
});
144+
145+
it('rejects messages from a sender who is not a room participant', () => {
146+
const service = new ChatService();
147+
const roomId = createRoom(service, ['alice']);
148+
149+
expect(() =>
150+
service.createMessage({
151+
roomId,
152+
senderId: 'mallory',
153+
content: 'hi',
154+
}),
155+
).toThrow(ForbiddenException);
156+
});
157+
158+
it('rejects code snippets from a sender who is not a room participant', () => {
159+
const service = new ChatService();
160+
const roomId = createRoom(service, ['alice']);
161+
162+
expect(() =>
163+
service.shareCodeSnippet({
164+
roomId,
165+
senderId: 'mallory',
166+
content: 'snippet',
167+
code: 'fn main() {}',
168+
language: 'rust',
169+
}),
170+
).toThrow(ForbiddenException);
171+
});
172+
173+
it('rejects reading messages for a room that does not exist', () => {
174+
const service = new ChatService();
175+
expect(() => service.findMessagesByRoom('missing-room')).toThrow(
176+
NotFoundException,
177+
);
178+
});
179+
180+
it('allows participants to post to a room', () => {
181+
const service = new ChatService();
182+
const roomId = createRoom(service, ['alice', 'bob']);
183+
184+
const msg = service.createMessage({
185+
roomId,
186+
senderId: 'bob',
187+
content: 'hello alice',
188+
});
189+
expect(msg.senderId).toBe('bob');
190+
});
191+
});
192+
193+
describe('ChatService pagination (#655)', () => {
194+
it('pages messages with a stable cursor', () => {
195+
const service = new ChatService();
196+
const roomId = createRoom(service, ['user-1']);
197+
for (let i = 0; i < 5; i++) {
198+
service.createMessage({ roomId, senderId: 'user-1', content: `msg-${i}` });
199+
}
200+
201+
const page1 = service.findMessagesByRoom(roomId, { limit: 2 });
202+
expect(page1.items).toHaveLength(2);
203+
expect(page1.nextCursor).toBeDefined();
204+
205+
const page2 = service.findMessagesByRoom(roomId, {
206+
limit: 2,
207+
cursor: page1.nextCursor,
208+
});
209+
expect(page2.items).toHaveLength(2);
210+
expect(page2.nextCursor).toBeDefined();
211+
212+
const page3 = service.findMessagesByRoom(roomId, {
213+
limit: 2,
214+
cursor: page2.nextCursor,
215+
});
216+
expect(page3.items).toHaveLength(1);
217+
expect(page3.nextCursor).toBeUndefined();
218+
});
219+
220+
it('pages rooms newest first', () => {
221+
const service = new ChatService();
222+
createRoom(service, ['u1'], 'room-a');
223+
createRoom(service, ['u2'], 'room-b');
224+
createRoom(service, ['u3'], 'room-c');
225+
226+
const page1 = service.findAllRooms({ limit: 2 });
227+
expect(page1.items).toHaveLength(2);
228+
expect(page1.nextCursor).toBeDefined();
229+
230+
const page2 = service.findAllRooms({ limit: 2, cursor: page1.nextCursor });
231+
expect(page2.items).toHaveLength(1);
232+
expect(page2.nextCursor).toBeUndefined();
233+
});
234+
});
235+
236+
describe('ChatService bounded storage (#655)', () => {
237+
it('evicts the oldest messages once a room exceeds its bound', () => {
238+
const repository = new InMemoryChatRepository({
239+
maxRooms: 10,
240+
maxMessagesPerRoom: 3,
241+
roomTtlMs: 60_000,
242+
messageTtlMs: 60_000,
243+
});
244+
const service = new ChatService(undefined, repository);
245+
const roomId = createRoom(service, ['user-1']);
246+
247+
for (let i = 0; i < 5; i++) {
248+
service.createMessage({ roomId, senderId: 'user-1', content: `msg-${i}` });
249+
}
250+
251+
const { items } = service.findMessagesByRoom(roomId);
252+
expect(items).toHaveLength(3);
253+
// Oldest messages were evicted first.
254+
expect(items.map((m) => m.content)).toEqual(['msg-2', 'msg-3', 'msg-4']);
255+
});
256+
257+
it('evicts the oldest room (and its messages) once the room bound is exceeded', () => {
258+
const repository = new InMemoryChatRepository({
259+
maxRooms: 2,
260+
maxMessagesPerRoom: 10,
261+
roomTtlMs: 60_000,
262+
messageTtlMs: 60_000,
263+
});
264+
const service = new ChatService(undefined, repository);
265+
266+
const room1 = createRoom(service, ['u1'], 'room-1');
267+
service.createMessage({ roomId: room1, senderId: 'u1', content: 'hi' });
268+
const room2 = createRoom(service, ['u2'], 'room-2');
269+
const room3 = createRoom(service, ['u3'], 'room-3');
270+
271+
expect(service.findRoomById(room1)).toBeUndefined();
272+
expect(service.findRoomById(room2)).toBeDefined();
273+
expect(service.findRoomById(room3)).toBeDefined();
274+
// Messages of the evicted room are gone too.
275+
expect(repository.messageCount).toBe(0);
276+
});
277+
278+
it('prunes messages and rooms past their retention window', () => {
279+
const repository = new InMemoryChatRepository({
280+
maxRooms: 10,
281+
maxMessagesPerRoom: 10,
282+
roomTtlMs: 1_000,
283+
messageTtlMs: 1_000,
284+
});
285+
const service = new ChatService(undefined, repository);
286+
const roomId = createRoom(service, ['user-1']);
287+
service.createMessage({ roomId, senderId: 'user-1', content: 'hi' });
288+
289+
// A "now" far in the future expires everything.
290+
service.pruneExpired(new Date(Date.now() + 60_000));
291+
292+
expect(repository.messageCount).toBe(0);
293+
expect(repository.roomCount).toBe(0);
294+
expect(service.findRoomById(roomId)).toBeUndefined();
295+
});
296+
});

0 commit comments

Comments
 (0)