1- import { HttpException , HttpStatus } from '@nestjs/common' ;
1+ import { ForbiddenException , HttpException , HttpStatus , NotFoundException } from '@nestjs/common' ;
22import { ChatService } from './chat.service' ;
33import {
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
815describe ( 'ChatRateLimiter' , ( ) => {
916 it ( 'allows messages up to the configured limit then blocks' , ( ) => {
@@ -43,9 +50,10 @@ describe('ChatRateLimiter', () => {
4350describe ( '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', () => {
8998describe ( '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