@@ -315,3 +315,105 @@ def assert_cookie_expired(self, name: str) -> Self:
315315 is_expired = max_age == 0 or max_age == "0" or cookie .value == ""
316316 assert is_expired , f"Cookie '{ name } ' is not expired (max-age={ max_age } , value='{ cookie .value } ')"
317317 return self # type: ignore[return-value]
318+
319+
320+ class StreamingAssertionsMixin :
321+ _response : HttpResponse
322+
323+ def _get_streaming_content (self ) -> str :
324+ # Streaming content can only be consumed once, so cache it for reuse
325+ from django .http import StreamingHttpResponse
326+
327+ if hasattr (self , "_cached_streaming_content" ):
328+ return self ._cached_streaming_content # type: ignore[return-value]
329+
330+ if isinstance (self ._response , StreamingHttpResponse ):
331+ content = b"" .join (self ._response .streaming_content ).decode ("utf-8" )
332+ else :
333+ content = self ._response .content .decode ("utf-8" )
334+
335+ self ._cached_streaming_content = content # type: ignore[attr-defined]
336+ return content
337+
338+ def _get_streaming_lines (self , strip_empty : bool = True ) -> list [str ]:
339+ # Handles both \n and \r\n line endings
340+ content = self ._get_streaming_content ()
341+ content = content .replace ("\r \n " , "\n " )
342+ lines = content .split ("\n " )
343+ if strip_empty :
344+ lines = [line for line in lines if line .strip ()]
345+ return lines
346+
347+ def assert_streaming (self ) -> Self :
348+ from django .http import StreamingHttpResponse
349+
350+ assert isinstance (self ._response , StreamingHttpResponse ), (
351+ f"Expected StreamingHttpResponse, got { type (self ._response ).__name__ } "
352+ )
353+ return self # type: ignore[return-value]
354+
355+ def assert_download (self , filename : str | None = None ) -> Self :
356+ # If filename provided, also asserts the filename matches.
357+ disposition = self ._response .get ("Content-Disposition" , "" )
358+ assert "attachment" in disposition , f"Expected Content-Disposition: attachment header, got '{ disposition } '"
359+ if filename is not None :
360+ assert f'filename="{ filename } "' in disposition or f"filename={ filename } " in disposition , (
361+ f"Expected filename '{ filename } ' in Content-Disposition, got '{ disposition } '"
362+ )
363+ return self # type: ignore[return-value]
364+
365+ def assert_streaming_contains (self , text : str ) -> Self :
366+ content = self ._get_streaming_content ()
367+ assert text in content , f"Expected streaming content to contain '{ text } '"
368+ return self # type: ignore[return-value]
369+
370+ def assert_streaming_not_contains (self , text : str ) -> Self :
371+ content = self ._get_streaming_content ()
372+ assert text not in content , f"Expected streaming content to NOT contain '{ text } '"
373+ return self # type: ignore[return-value]
374+
375+ def assert_streaming_matches (self , pattern : str ) -> Self :
376+ content = self ._get_streaming_content ()
377+ assert re .search (pattern , content ), f"Expected streaming content to match pattern '{ pattern } '"
378+ return self # type: ignore[return-value]
379+
380+ def assert_streaming_line_count (
381+ self , exact : int | None = None , * , min : int | None = None , max : int | None = None
382+ ) -> Self :
383+ # Counts non-empty lines only
384+ lines = self ._get_streaming_lines ()
385+ count = len (lines )
386+
387+ if exact is not None :
388+ assert count == exact , f"Expected exactly { exact } lines, got { count } "
389+ if min is not None :
390+ assert count >= min , f"Expected at least { min } lines, got { count } "
391+ if max is not None :
392+ assert count <= max , f"Expected at most { max } lines, got { count } "
393+
394+ return self # type: ignore[return-value]
395+
396+ def assert_streaming_empty (self ) -> Self :
397+ # Checks for no non-empty lines
398+ lines = self ._get_streaming_lines ()
399+ assert len (lines ) == 0 , f"Expected empty streaming content, got { len (lines )} lines"
400+ return self # type: ignore[return-value]
401+
402+ def assert_streaming_csv_header (self , columns : list [str ] | str ) -> Self :
403+ # columns: list of names or comma-separated string
404+ lines = self ._get_streaming_lines (strip_empty = False )
405+ has_content = len (lines ) > 0 and lines [0 ].strip ()
406+ assert has_content , "Expected CSV content but response is empty"
407+
408+ header = lines [0 ]
409+ expected = "," .join (columns ) if isinstance (columns , list ) else columns
410+
411+ assert header == expected , f"Expected CSV header '{ expected } ', got '{ header } '"
412+ return self # type: ignore[return-value] # type: ignore[return-value]
413+
414+ def assert_streaming_line (self , index : int , expected : str ) -> Self :
415+ lines = self ._get_streaming_lines ()
416+ assert index < len (lines ), f"Line index { index } out of range (only { len (lines )} lines)"
417+ actual = lines [index ]
418+ assert actual == expected , f"Line { index } : expected '{ expected } ', got '{ actual } '"
419+ return self # type: ignore[return-value]
0 commit comments