""" Tests for Org Social Host application. Following the Given/When/Then pattern from org-social-relay. """ import requests from django.conf import settings from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient from .models import HostedFile from .utils import ( build_vfile_url, extract_nick_from_content, generate_vfile_token, validate_nickname, ) class RootViewTest(TestCase): """Test cases for the root endpoint.""" def setUp(self): self.client = APIClient() self.root_url = "/" def test_root_endpoint_success(self): """Test GET / returns success response with HATEOAS links.""" # Given: The root endpoint # When: We request the root endpoint response = self.client.get(self.root_url) # Then: We should get success response self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["type"], "Success") self.assertEqual(response.json()["errors"], []) # Then: Should have data with name and description data = response.json()["data"] self.assertIn("name", data) self.assertIn("description", data) self.assertEqual(data["name"], "Org Social Host") def test_root_endpoint_hateoas_links(self): """Test GET / returns all expected HATEOAS links.""" # Given: The root endpoint # When: We request the root endpoint response = self.client.get(self.root_url) # Then: Should have _links with all endpoints links = response.json()["_links"] expected_links = [ "self", "signup", "upload", "delete", "redirect", "remove-redirect", ] for link_name in expected_links: self.assertIn(link_name, links, f"Missing link: {link_name}") def test_root_endpoint_response_format_compliance(self): """Test root endpoint response format compliance.""" # Given: The root endpoint # When: We request the root endpoint response = self.client.get(self.root_url) # Then: Response should match expected format self.assertEqual(response.status_code, status.HTTP_200_OK) response_data = response.json() self.assertIn("type", response_data) self.assertIn("errors", response_data) self.assertIn("data", response_data) self.assertIn("_links", response_data) self.assertIsInstance(response_data["errors"], list) self.assertIsInstance(response_data["data"], dict) self.assertIsInstance(response_data["_links"], dict) class SignupViewTest(TestCase): """Test cases for the signup endpoint.""" def setUp(self): self.client = APIClient() self.signup_url = "/signup" def test_signup_success(self): """Test POST /signup with valid nickname creates account.""" # Given: A valid nickname that doesn't exist nickname = "test_user" # When: We signup with the nickname response = self.client.post( self.signup_url, {"nick": nickname}, format="json", ) # Then: We should get success response self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["type"], "Success") self.assertEqual(response.json()["errors"], []) # Then: Response should contain vfile and public-url data = response.json()["data"] self.assertIn("vfile", data) self.assertIn("public-url", data) # Then: vfile should be a valid URL with required params vfile = data["vfile"] self.assertIn("token=", vfile) self.assertIn("ts=", vfile) self.assertIn("sig=", vfile) # Then: public-url should match expected format public_url = data["public-url"] self.assertIn(f"/{nickname}/social.org", public_url) # Then: HostedFile should be created in database hosted_file = HostedFile.objects.get(nickname=nickname) self.assertEqual(hosted_file.nickname, nickname) self.assertIsNotNone(hosted_file.vfile_token) def test_signup_nickname_already_taken(self): """Test POST /signup with existing nickname returns error.""" # Given: A nickname that already exists nickname = "existing_user" HostedFile.objects.create( nickname=nickname, vfile_token="token123", vfile_timestamp=1234567890, vfile_signature="sig123", file_content="", ) # When: We try to signup with the same nickname response = self.client.post( self.signup_url, {"nick": nickname}, format="json", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["type"], "Error") self.assertIn("already taken", response.json()["errors"][0].lower()) def test_signup_invalid_nickname_too_short(self): """Test POST /signup with too short nickname returns error.""" # Given: A nickname that is too short nickname = "ab" # When: We try to signup response = self.client.post( self.signup_url, {"nick": nickname}, format="json", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["type"], "Error") self.assertIn("at least 3 characters", response.json()["errors"][0].lower()) def test_signup_invalid_nickname_special_chars(self): """Test POST /signup with invalid characters returns error.""" # Given: A nickname with invalid characters nickname = "user@name" # When: We try to signup response = self.client.post( self.signup_url, {"nick": nickname}, format="json", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["type"], "Error") self.assertTrue( any("letters" in err.lower() or "alphanumeric" in err.lower() for err in response.json()["errors"]) ) def test_signup_missing_nickname(self): """Test POST /signup without nickname returns error.""" # Given: No nickname provided # When: We try to signup without nickname response = self.client.post( self.signup_url, {}, format="json", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["type"], "Error") class UploadViewTest(TestCase): """Test cases for the upload endpoint.""" def setUp(self): self.client = APIClient() self.upload_url = "/upload" # Create a test user self.nickname = "test_user" token_data = generate_vfile_token(self.nickname) self.vfile = build_vfile_url( token_data["token"], token_data["timestamp"], token_data["signature"], ) # Create hosted file self.hosted_file = HostedFile.objects.create( nickname=self.nickname, vfile_token=token_data["token"], vfile_timestamp=token_data["timestamp"], vfile_signature=token_data["signature"], file_content="", ) def test_upload_success(self): """Test POST /upload with valid vfile uploads file.""" # Given: A valid vfile and file content file_content = b"#+TITLE: Test\n\n* Posts\n** Test post\n" # Create a file-like object from io import BytesIO file = BytesIO(file_content) file.name = "social.org" # When: We upload the file response = self.client.post( self.upload_url, { "vfile": self.vfile, "file": file, }, format="multipart", ) # Then: We should get success response self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["type"], "Success") self.assertEqual(response.json()["errors"], []) # Then: Response should contain confirmation data = response.json()["data"] self.assertIn("message", data) self.assertIn("public-url", data) # Then: Content should be saved in database self.hosted_file.refresh_from_db() self.assertEqual(self.hosted_file.file_content, file_content.decode("utf-8")) def test_upload_invalid_vfile(self): """Test POST /upload with invalid vfile returns error.""" # Given: An invalid vfile invalid_vfile = "http://localhost/vfile?token=invalid&ts=123&sig=bad" file_content = b"#+TITLE: Test\n" from io import BytesIO file = BytesIO(file_content) file.name = "social.org" # When: We try to upload with invalid vfile response = self.client.post( self.upload_url, { "vfile": invalid_vfile, "file": file, }, format="multipart", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(response.json()["type"], "Error") self.assertIn("invalid", response.json()["errors"][0].lower()) def test_upload_file_too_large(self): """Test POST /upload with file exceeding size limit returns error.""" # Given: A file that exceeds MAX_FILE_SIZE large_content = b"X" * (settings.MAX_FILE_SIZE + 1) from io import BytesIO file = BytesIO(large_content) file.name = "social.org" # When: We try to upload the large file response = self.client.post( self.upload_url, { "vfile": self.vfile, "file": file, }, format="multipart", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) self.assertEqual(response.json()["type"], "Error") self.assertIn("too large", response.json()["errors"][0].lower()) def test_upload_missing_file(self): """Test POST /upload without file returns error.""" # Given: No file provided # When: We try to upload without file response = self.client.post( self.upload_url, {"vfile": self.vfile}, format="multipart", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["type"], "Error") def test_upload_rejects_nick_matching_other_nickname(self): """Test POST /upload rejects file whose #+NICK matches another account's nickname.""" # Given: Another registered account other_nickname = "alice" other_token = generate_vfile_token(other_nickname) HostedFile.objects.create( nickname=other_nickname, vfile_token=other_token["token"], vfile_timestamp=other_token["timestamp"], vfile_signature=other_token["signature"], file_content="", ) # Given: An upload that declares the other account's nickname as #+NICK file_content = f"#+TITLE: Stolen\n#+NICK: {other_nickname}\n\n* Posts\n".encode() from io import BytesIO file = BytesIO(file_content) file.name = "social.org" # When: We try to upload response = self.client.post( self.upload_url, {"vfile": self.vfile, "file": file}, format="multipart", ) # Then: We should get a 409 Conflict self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(response.json()["type"], "Error") self.assertIn("already in use", response.json()["errors"][0].lower()) # Then: Our file content must remain unchanged self.hosted_file.refresh_from_db() self.assertEqual(self.hosted_file.file_content, "") def test_upload_rejects_nick_present_in_other_file_content(self): """Test POST /upload rejects file whose #+NICK matches another account's #+NICK.""" # Given: Another registered account whose stored file declares #+NICK: bob other_nickname = "other_user" other_token = generate_vfile_token(other_nickname) HostedFile.objects.create( nickname=other_nickname, vfile_token=other_token["token"], vfile_timestamp=other_token["timestamp"], vfile_signature=other_token["signature"], file_content="#+TITLE: Bob\n#+NICK: bob\n\n* Posts\n", ) # Given: An upload declaring the same #+NICK file_content = b"#+TITLE: Impostor\n#+NICK: bob\n\n* Posts\n" from io import BytesIO file = BytesIO(file_content) file.name = "social.org" # When: We try to upload response = self.client.post( self.upload_url, {"vfile": self.vfile, "file": file}, format="multipart", ) # Then: We should get a 409 Conflict self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(response.json()["type"], "Error") self.assertIn("bob", response.json()["errors"][0].lower()) def test_upload_allows_own_nick(self): """Test POST /upload accepts a file whose #+NICK matches the uploader's own nickname.""" # Given: A file content declaring the uploader's own nickname file_content = f"#+TITLE: Mine\n#+NICK: {self.nickname}\n\n* Posts\n".encode() from io import BytesIO file = BytesIO(file_content) file.name = "social.org" # When: We upload response = self.client.post( self.upload_url, {"vfile": self.vfile, "file": file}, format="multipart", ) # Then: Upload succeeds self.assertEqual(response.status_code, status.HTTP_200_OK) self.hosted_file.refresh_from_db() self.assertEqual(self.hosted_file.file_content, file_content.decode("utf-8")) class DeleteViewTest(TestCase): """Test cases for the delete endpoint.""" def setUp(self): self.client = APIClient() self.delete_url = "/delete" # Create a test user self.nickname = "test_user" token_data = generate_vfile_token(self.nickname) self.vfile = build_vfile_url( token_data["token"], token_data["timestamp"], token_data["signature"], ) # Create hosted file self.hosted_file = HostedFile.objects.create( nickname=self.nickname, vfile_token=token_data["token"], vfile_timestamp=token_data["timestamp"], vfile_signature=token_data["signature"], file_content="", ) def test_delete_success(self): """Test POST /delete with valid vfile deletes account.""" # Given: A valid vfile for existing account # When: We delete the account response = self.client.post( self.delete_url, {"vfile": self.vfile}, format="json", ) # Then: We should get success response self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["type"], "Success") self.assertEqual(response.json()["errors"], []) # Then: HostedFile should be deleted from database self.assertFalse( HostedFile.objects.filter(nickname=self.nickname).exists() ) def test_delete_invalid_vfile(self): """Test POST /delete with invalid vfile returns error.""" # Given: An invalid vfile invalid_vfile = "http://localhost/vfile?token=invalid&ts=123&sig=bad" # When: We try to delete with invalid vfile response = self.client.post( self.delete_url, {"vfile": invalid_vfile}, format="json", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(response.json()["type"], "Error") self.assertIn("invalid", response.json()["errors"][0].lower()) # Then: HostedFile should still exist self.assertTrue( HostedFile.objects.filter(nickname=self.nickname).exists() ) def test_delete_nonexistent_account(self): """Test POST /delete for non-existent account returns error.""" # Given: A valid vfile for non-existent account token_data = generate_vfile_token("nonexistent") vfile = build_vfile_url( token_data["token"], token_data["timestamp"], token_data["signature"], ) # When: We try to delete non-existent account response = self.client.post( self.delete_url, {"vfile": vfile}, format="json", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.json()["type"], "Error") class RedirectViewTest(TestCase): """Test cases for the redirect endpoint.""" def setUp(self): self.client = APIClient() self.redirect_url = "/redirect" # Create a test user self.nickname = "test_user" token_data = generate_vfile_token(self.nickname) self.vfile = build_vfile_url( token_data["token"], token_data["timestamp"], token_data["signature"], ) # Create hosted file self.hosted_file = HostedFile.objects.create( nickname=self.nickname, vfile_token=token_data["token"], vfile_timestamp=token_data["timestamp"], vfile_signature=token_data["signature"], file_content="", ) def test_redirect_success(self): """Test POST /redirect with valid vfile sets redirect.""" # Given: A valid vfile and new URL new_url = "https://my-domain.org/social.org" # When: We set up a redirect response = self.client.post( self.redirect_url, { "vfile": self.vfile, "new-url": new_url, }, format="json", ) # Then: We should get success response self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["type"], "Success") self.assertEqual(response.json()["errors"], []) # Then: Response should contain redirect URL data = response.json()["data"] self.assertIn("redirect-url", data) self.assertEqual(data["redirect-url"], new_url) # Then: HostedFile should have redirect_url set self.hosted_file.refresh_from_db() self.assertEqual(self.hosted_file.redirect_url, new_url) def test_redirect_invalid_url(self): """Test POST /redirect with invalid URL returns error.""" # Given: An invalid URL invalid_url = "not-a-valid-url" # When: We try to set redirect with invalid URL response = self.client.post( self.redirect_url, { "vfile": self.vfile, "new-url": invalid_url, }, format="json", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["type"], "Error") # Then: HostedFile should not have redirect_url set self.hosted_file.refresh_from_db() self.assertIsNone(self.hosted_file.redirect_url) class RemoveRedirectViewTest(TestCase): """Test cases for the remove-redirect endpoint.""" def setUp(self): self.client = APIClient() self.remove_redirect_url = "/remove-redirect" # Create a test user with redirect self.nickname = "test_user" token_data = generate_vfile_token(self.nickname) self.vfile = build_vfile_url( token_data["token"], token_data["timestamp"], token_data["signature"], ) # Create hosted file with redirect self.hosted_file = HostedFile.objects.create( nickname=self.nickname, vfile_token=token_data["token"], vfile_timestamp=token_data["timestamp"], vfile_signature=token_data["signature"], file_content="", redirect_url="https://other-domain.org/social.org", ) def test_remove_redirect_success(self): """Test POST /remove-redirect with valid vfile removes redirect.""" # Given: A valid vfile for account with redirect # When: We remove the redirect response = self.client.post( self.remove_redirect_url, {"vfile": self.vfile}, format="json", ) # Then: We should get success response self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["type"], "Success") self.assertEqual(response.json()["errors"], []) # Then: HostedFile should not have redirect_url self.hosted_file.refresh_from_db() self.assertIsNone(self.hosted_file.redirect_url) def test_remove_redirect_no_redirect_configured(self): """Test POST /remove-redirect when no redirect exists returns error.""" # Given: An account without redirect self.hosted_file.redirect_url = None self.hosted_file.save() # When: We try to remove redirect response = self.client.post( self.remove_redirect_url, {"vfile": self.vfile}, format="json", ) # Then: We should get error response self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["type"], "Error") self.assertIn("no redirect", response.json()["errors"][0].lower()) class ServeFileViewTest(TestCase): """Test cases for the serve file endpoint.""" def setUp(self): self.client = APIClient() # Create a test user self.nickname = "test_user" token_data = generate_vfile_token(self.nickname) # Create hosted file with content self.file_content = "#+TITLE: Test\n\n* Posts\n** Test post\n" self.hosted_file = HostedFile.objects.create( nickname=self.nickname, vfile_token=token_data["token"], vfile_timestamp=token_data["timestamp"], vfile_signature=token_data["signature"], file_content=self.file_content, ) def test_serve_file_success(self): """Test GET //social.org serves file.""" # Given: A file exists for the nickname serve_url = f"/{self.nickname}/social.org" # When: We request the file response = self.client.get(serve_url) # Then: We should get the file content self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.content.decode("utf-8"), self.file_content) self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8") def test_serve_file_not_found(self): """Test GET //social.org for non-existent file returns 404.""" # Given: A nickname that doesn't exist serve_url = "/nonexistent/social.org" # When: We request the file response = self.client.get(serve_url) # Then: We should get 404 self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_serve_file_with_redirect(self): """Test GET //social.org with redirect returns 301.""" # Given: A file with redirect configured redirect_url = "https://new-domain.org/social.org" self.hosted_file.redirect_url = redirect_url self.hosted_file.save() serve_url = f"/{self.nickname}/social.org" # When: We request the file response = self.client.get(serve_url, follow=False) # Then: We should get 301 redirect self.assertEqual(response.status_code, status.HTTP_301_MOVED_PERMANENTLY) self.assertEqual(response["Location"], redirect_url) class UtilsTest(TestCase): """Test cases for utility functions.""" def test_generate_vfile_token(self): """Test generate_vfile_token creates valid token.""" # Given: A nickname nickname = "test_user" # When: We generate a token token_data = generate_vfile_token(nickname) # Then: Token should have required fields self.assertIn("token", token_data) self.assertIn("timestamp", token_data) self.assertIn("signature", token_data) # Then: Token should be 64 hex characters (256 bits) self.assertEqual(len(token_data["token"]), 64) # Then: Timestamp should be an integer self.assertIsInstance(token_data["timestamp"], int) # Then: Signature should be a hex string self.assertTrue(all(c in "0123456789abcdef" for c in token_data["signature"])) def test_validate_nickname_valid(self): """Test validate_nickname accepts valid nicknames.""" # Given: Valid nicknames valid_nicknames = [ "user123", "test_user", "user-name", "ABC", "a_b_c", ] # When/Then: All should be valid for nickname in valid_nicknames: is_valid, error = validate_nickname(nickname) self.assertTrue(is_valid, f"{nickname} should be valid: {error}") self.assertEqual(error, "") def test_extract_nick_from_content(self): """Test extract_nick_from_content parses the #+NICK directive.""" # Given: Various social.org contents cases = [ ("#+TITLE: x\n#+NICK: alice\n", "alice"), ("#+nick: Bob\n", "Bob"), (" #+NICK: carol \n", "carol"), ("#+TITLE: nope\n", None), ("", None), (None, None), ] # When/Then: Each content extracts the expected nick for content, expected in cases: self.assertEqual(extract_nick_from_content(content), expected) def test_validate_nickname_invalid(self): """Test validate_nickname rejects invalid nicknames.""" # Given: Invalid nicknames invalid_cases = [ ("", "Nickname is required"), ("ab", "at least 3 characters"), ("a" * 51, "at most 50 characters"), ("user@name", "letters, numbers"), ("user name", "letters, numbers"), ] # When/Then: All should be invalid for nickname, expected_error in invalid_cases: is_valid, error = validate_nickname(nickname) self.assertFalse(is_valid, f"{nickname} should be invalid") self.assertIn(expected_error.lower(), error.lower()) class SyncFeedsToRelayTaskTest(TestCase): """Test cases for the sync_feeds_to_relay periodic task.""" def setUp(self): from app.hosting import tasks self.tasks = tasks # Three accounts: one with content, one redirected, one empty. self.f_with_content = HostedFile.objects.create( nickname="alice", vfile_token="t-alice", vfile_timestamp=1, vfile_signature="s", file_content="#+TITLE: Alice\n#+NICK: alice\n", ) self.f_redirected = HostedFile.objects.create( nickname="bob", vfile_token="t-bob", vfile_timestamp=1, vfile_signature="s", file_content="#+TITLE: Bob\n#+NICK: bob\n", redirect_url="https://example.org/social.org", ) self.f_empty = HostedFile.objects.create( nickname="carol", vfile_token="t-carol", vfile_timestamp=1, vfile_signature="s", file_content="", ) def test_sync_skipped_when_relay_url_empty(self): """Test the task is a no-op if RELAY_URL is empty.""" # Given: RELAY_URL is empty from unittest.mock import patch # When: We invoke the task with patch.object(self.tasks.settings, "RELAY_URL", ""), \ patch.object(self.tasks.requests, "post") as post_mock, \ patch.object(self.tasks.time, "sleep") as sleep_mock: self.tasks.sync_feeds_to_relay.call_local() # Then: No HTTP call is performed post_mock.assert_not_called() sleep_mock.assert_not_called() def test_sync_posts_only_public_feeds(self): """Test the task posts only feeds with content and no redirect.""" # Given: A configured relay from unittest.mock import MagicMock, patch response = MagicMock() response.status_code = 201 with patch.object(self.tasks.settings, "RELAY_URL", "https://relay.test"), \ patch.object(self.tasks.settings, "SITE_DOMAIN", "host.example.org"), \ patch.object(self.tasks.requests, "post", return_value=response) as post_mock, \ patch.object(self.tasks.time, "sleep") as sleep_mock: self.tasks.sync_feeds_to_relay.call_local() # Then: Only the alice feed is posted (bob redirected, carol empty) self.assertEqual(post_mock.call_count, 1) args, kwargs = post_mock.call_args self.assertEqual(args[0], "https://relay.test/feeds/") self.assertEqual( kwargs["json"], {"feed": "https://host.example.org/alice/social.org"}, ) # Then: No sleep needed for a single feed sleep_mock.assert_not_called() def test_sync_sleeps_between_requests(self): """Test the task sleeps 1 second between requests, but not after the last one.""" # Given: Two more accounts with content so we get three total HostedFile.objects.create( nickname="dave", vfile_token="t-dave", vfile_timestamp=1, vfile_signature="s", file_content="content", ) HostedFile.objects.create( nickname="eve", vfile_token="t-eve", vfile_timestamp=1, vfile_signature="s", file_content="content", ) from unittest.mock import MagicMock, patch response = MagicMock() response.status_code = 200 # When: We run the task with the relay configured with patch.object(self.tasks.settings, "RELAY_URL", "https://relay.test"), \ patch.object(self.tasks.settings, "SITE_DOMAIN", "host.example.org"), \ patch.object(self.tasks.requests, "post", return_value=response) as post_mock, \ patch.object(self.tasks.time, "sleep") as sleep_mock: self.tasks.sync_feeds_to_relay.call_local() # Then: Three POSTs are made, two sleeps (between, not after the last) self.assertEqual(post_mock.call_count, 3) self.assertEqual(sleep_mock.call_count, 2) for call in sleep_mock.call_args_list: self.assertEqual(call.args[0], self.tasks.RELAY_SYNC_REQUEST_DELAY) def test_sync_continues_on_request_failure(self): """Test that a failed request does not stop the task.""" from unittest.mock import patch # Given: A second account with content so we have two feeds HostedFile.objects.create( nickname="dave", vfile_token="t-dave", vfile_timestamp=1, vfile_signature="s", file_content="content", ) # When: requests.post raises for the first call and succeeds for the second from unittest.mock import MagicMock ok_response = MagicMock() ok_response.status_code = 201 side_effects = [requests.ConnectionError("boom"), ok_response] with patch.object(self.tasks.settings, "RELAY_URL", "https://relay.test"), \ patch.object(self.tasks.settings, "SITE_DOMAIN", "host.example.org"), \ patch.object(self.tasks.requests, "post", side_effect=side_effects) as post_mock, \ patch.object(self.tasks.time, "sleep"): self.tasks.sync_feeds_to_relay.call_local() # Then: Both feeds were attempted self.assertEqual(post_mock.call_count, 2)