Files
org-social-relay/app/replies/tests.py
2025-11-05 15:03:20 +01:00

499 lines
20 KiB
Python

from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework import status
from app.feeds.models import Profile, Post
class RepliesViewTest(TestCase):
"""Test cases for the RepliesView API using Given/When/Then structure."""
def setUp(self):
self.client = APIClient()
self.replies_url = "/replies/"
# Create test profiles
self.profile1 = Profile.objects.create(
feed="https://example.com/social.org",
title="Example Profile",
nick="example_user",
description="Test profile 1",
)
self.profile2 = Profile.objects.create(
feed="https://test.com/social.org",
title="Test Profile",
nick="test_user",
description="Test profile 2",
)
self.profile3 = Profile.objects.create(
feed="https://third.com/social.org",
title="Third Profile",
nick="third_user",
description="Test profile 3",
)
# Create original post
self.original_post = Post.objects.create(
profile=self.profile1,
post_id="2025-01-01T12:00:00+00:00",
content="This is the original post",
)
# Create direct replies to original post
self.reply1 = Post.objects.create(
profile=self.profile2,
post_id="2025-01-01T13:00:00+00:00",
content="First reply to original",
reply_to=f"{self.profile1.feed}#{self.original_post.post_id}",
)
self.reply2 = Post.objects.create(
profile=self.profile3,
post_id="2025-01-01T14:00:00+00:00",
content="Second reply to original",
reply_to=f"{self.profile1.feed}#{self.original_post.post_id}",
)
# Create nested replies (replies to replies)
self.nested_reply1 = Post.objects.create(
profile=self.profile3,
post_id="2025-01-01T15:00:00+00:00",
content="Reply to first reply",
reply_to=f"{self.profile2.feed}#{self.reply1.post_id}",
)
self.nested_reply2 = Post.objects.create(
profile=self.profile1,
post_id="2025-01-01T16:00:00+00:00",
content="Another reply to first reply",
reply_to=f"{self.profile2.feed}#{self.reply1.post_id}",
)
# Create deeply nested reply
self.deep_nested_reply = Post.objects.create(
profile=self.profile2,
post_id="2025-01-01T17:00:00+00:00",
content="Reply to nested reply",
reply_to=f"{self.profile3.feed}#{self.nested_reply1.post_id}",
)
def test_get_replies_success(self):
"""Test GET /replies/?post=<post_url> returns replies tree structure."""
# Given: A post with replies exists
post_url = f"{self.profile1.feed}#{self.original_post.post_id}"
# When: We request replies for the post
response = self.client.get(self.replies_url, {"post": post_url})
# Then: We should get replies successfully
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["type"], "Success")
self.assertEqual(response.data["errors"], [])
# Then: Response should contain tree structure data
data = response.data["data"]
self.assertIsInstance(data, list)
self.assertEqual(len(data), 2) # 2 direct replies
# Then: Each reply should have post and children fields
for reply in data:
self.assertIn("post", reply)
self.assertIn("children", reply)
self.assertIsInstance(reply["children"], list)
# Then: Meta should contain correct information
meta = response.data["meta"]
self.assertEqual(meta["parent"], post_url)
# Then: Should have ETag and Last-Modified headers
self.assertIn("ETag", response)
self.assertIn("Last-Modified", response)
def test_get_replies_nested_structure(self):
"""Test that replies are properly nested in tree structure."""
# Given: A post with nested replies exists
post_url = f"{self.profile1.feed}#{self.original_post.post_id}"
# When: We request replies for the post
response = self.client.get(self.replies_url, {"post": post_url})
# Then: We should get properly nested structure
data = response.data["data"]
# Find the first reply which has children
reply_with_children = None
for reply in data:
if reply["post"] == f"{self.profile2.feed}#{self.reply1.post_id}":
reply_with_children = reply
break
self.assertIsNotNone(reply_with_children)
self.assertEqual(len(reply_with_children["children"]), 2) # 2 nested replies
# Then: Check that nested reply has its own child
nested_reply_with_child = None
for child in reply_with_children["children"]:
if child["post"] == f"{self.profile3.feed}#{self.nested_reply1.post_id}":
nested_reply_with_child = child
break
self.assertIsNotNone(nested_reply_with_child)
self.assertEqual(
len(nested_reply_with_child["children"]), 1
) # 1 deep nested reply
def test_get_replies_nonexistent_post(self):
"""Test GET /replies/ returns 404 for nonexistent post."""
# Given: A post ID that doesn't exist
nonexistent_post_url = f"{self.profile1.feed}#2025-01-01T00:00:00+00:00"
# When: We request replies for nonexistent post
response = self.client.get(self.replies_url, {"post": nonexistent_post_url})
# Then: We should get 404 error
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["type"], "Error")
self.assertIn("Post not found", response.data["errors"][0])
self.assertIsNone(response.data["data"])
def test_get_replies_nonexistent_profile(self):
"""Test GET /replies/ returns 404 for nonexistent profile."""
# Given: A profile feed that doesn't exist
nonexistent_post_url = (
f"https://nonexistent.com/social.org#{self.original_post.post_id}"
)
# When: We request replies for post from nonexistent profile
response = self.client.get(self.replies_url, {"post": nonexistent_post_url})
# Then: We should get 404 error
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["type"], "Error")
self.assertIn("Post not found", response.data["errors"][0])
self.assertIsNone(response.data["data"])
def test_get_replies_no_replies(self):
"""Test GET /replies/ returns empty array for post with no replies."""
# Given: A post with no replies
lonely_post = Post.objects.create(
profile=self.profile1,
post_id="2025-01-01T20:00:00+00:00",
content="This post has no replies",
)
post_url = f"{self.profile1.feed}#{lonely_post.post_id}"
# When: We request replies for the post
response = self.client.get(self.replies_url, {"post": post_url})
# Then: We should get empty array
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["type"], "Success")
self.assertEqual(response.data["errors"], [])
self.assertEqual(response.data["data"], [])
# Then: Meta should still be present
meta = response.data["meta"]
self.assertEqual(meta["parent"], post_url)
# Then: Should have ETag and Last-Modified headers
self.assertIn("ETag", response)
self.assertIn("Last-Modified", response)
def test_replies_response_format_compliance(self):
"""Test replies response format compliance with README specification."""
# Given: A post with replies exists
post_url = f"{self.profile1.feed}#{self.original_post.post_id}"
# When: We request replies
response = self.client.get(self.replies_url, {"post": post_url})
# Then: Response should match expected format
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("type", response.data)
self.assertIn("errors", response.data)
self.assertIn("data", response.data)
self.assertIn("meta", response.data)
self.assertEqual(response.data["type"], "Success")
self.assertIsInstance(response.data["errors"], list)
self.assertIsInstance(response.data["data"], list)
self.assertIsInstance(response.data["meta"], dict)
# Then: Data should be array of reply trees
data = response.data["data"]
for reply_tree in data:
self.assertIn("post", reply_tree)
self.assertIn("children", reply_tree)
self.assertIn("moods", reply_tree)
self.assertIsInstance(reply_tree["children"], list)
self.assertIsInstance(reply_tree["moods"], list)
def test_replies_view_methods_allowed(self):
"""Test that only GET method is allowed on replies endpoint."""
# Given: A valid replies URL
params = {"post": f"{self.profile1.feed}#{self.original_post.post_id}"}
# When: We try different HTTP methods
post_response = self.client.post(self.replies_url, params)
put_response = self.client.put(self.replies_url, params)
delete_response = self.client.delete(self.replies_url, params)
patch_response = self.client.patch(self.replies_url, params)
# Then: Unsupported methods should return 405
self.assertEqual(post_response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertEqual(put_response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertEqual(
delete_response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED
)
self.assertEqual(patch_response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
def test_replies_missing_parameters(self):
"""Test that missing required parameters return 400 error."""
# Given: Replies endpoint
# When: We request without required parameters
response = self.client.get(self.replies_url)
# Then: Should return 400 error
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["type"], "Error")
self.assertIn("required", response.data["errors"][0])
def test_replies_invalid_post_format(self):
"""Test that invalid post URL format returns 400 error."""
# Given: Replies endpoint
# When: We request with invalid post format (no # separator)
response = self.client.get(self.replies_url, {"post": "invalid_url_format"})
# Then: Should return 400 error
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["type"], "Error")
self.assertIn("Invalid post URL format", response.data["errors"][0])
def test_replies_with_moods(self):
"""Test replies with mood reactions are properly included."""
# Given: An original post
original_post = Post.objects.create(
profile=self.profile1,
post_id="2025-01-02T12:00:00+00:00",
content="Original post for mood testing",
)
# And: A regular reply
regular_reply = Post.objects.create(
profile=self.profile2,
post_id="2025-01-02T13:00:00+00:00",
content="This is a regular reply",
reply_to=f"{self.profile1.feed}#{original_post.post_id}",
)
# And: Mood reactions (empty content + mood + reply_to)
Post.objects.create(
profile=self.profile2,
post_id="2025-01-02T13:30:00+00:00",
content="", # Empty content for mood reaction
mood="",
reply_to=f"{self.profile1.feed}#{original_post.post_id}",
)
thumbs_up_reaction = Post.objects.create(
profile=self.profile1,
post_id="2025-01-02T14:00:00+00:00",
content=" ", # Whitespace content for mood reaction
mood="👍",
reply_to=f"{self.profile2.feed}#{regular_reply.post_id}",
)
rocket_reaction = Post.objects.create(
profile=self.profile2,
post_id="2025-01-02T14:30:00+00:00",
content="",
mood="🚀",
reply_to=f"{self.profile2.feed}#{regular_reply.post_id}",
)
# When: We request replies for the original post
post_url = f"{self.profile1.feed}#{original_post.post_id}"
response = self.client.get(self.replies_url, {"post": post_url})
# Then: Should return success with moods included
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data["data"]
# Should have 1 regular reply
self.assertEqual(len(data), 1)
reply_tree = data[0]
self.assertEqual(
reply_tree["post"], f"{self.profile2.feed}#{regular_reply.post_id}"
)
# Regular reply should have moods (👍 and 🚀)
self.assertIn("moods", reply_tree)
moods = reply_tree["moods"]
self.assertEqual(len(moods), 2)
# Check moods are correctly grouped
mood_emojis = [mood["emoji"] for mood in moods]
self.assertIn("👍", mood_emojis)
self.assertIn("🚀", mood_emojis)
# Check posts for each mood
thumbs_mood = next(mood for mood in moods if mood["emoji"] == "👍")
rocket_mood = next(mood for mood in moods if mood["emoji"] == "🚀")
self.assertEqual(len(thumbs_mood["posts"]), 1)
self.assertEqual(len(rocket_mood["posts"]), 1)
self.assertIn(
f"{self.profile1.feed}#{thumbs_up_reaction.post_id}", thumbs_mood["posts"]
)
self.assertIn(
f"{self.profile2.feed}#{rocket_reaction.post_id}", rocket_mood["posts"]
)
def test_replies_format_with_moods(self):
"""Test that all replies include moods field even when empty."""
# Given: A separate original post for this test
original_post_for_moods = Post.objects.create(
profile=self.profile1,
post_id="2025-01-03T12:00:00+00:00",
content="Original post for moods format test",
)
# And: A simple reply without any mood reactions
Post.objects.create(
profile=self.profile2,
post_id="2025-01-03T13:00:00+00:00",
content="Simple reply without reactions",
reply_to=f"{self.profile1.feed}#{original_post_for_moods.post_id}",
)
# When: We request replies
post_url = f"{self.profile1.feed}#{original_post_for_moods.post_id}"
response = self.client.get(self.replies_url, {"post": post_url})
# Then: All replies should have moods field
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data["data"]
self.assertEqual(len(data), 1)
reply_tree = data[0]
# Should have moods field even if empty
self.assertIn("moods", reply_tree)
self.assertIsInstance(reply_tree["moods"], list)
self.assertEqual(len(reply_tree["moods"]), 0)
def test_meta_includes_parent_chain(self):
"""Test that meta includes parentChain field for requested post."""
# Given: A post that is a reply (has parents)
# Create a root
root_post = Post.objects.create(
profile=self.profile1,
post_id="2025-01-10T10:00:00+00:00",
content="Root post",
)
# Create a reply to root
reply_post = Post.objects.create(
profile=self.profile2,
post_id="2025-01-10T11:00:00+00:00",
content="Reply to root",
reply_to=f"{self.profile1.feed}#{root_post.post_id}",
)
post_url = f"{self.profile2.feed}#{reply_post.post_id}"
# When: We request replies for the reply_post
response = self.client.get(self.replies_url, {"post": post_url})
# Then: meta should have parentChain
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("meta", response.data)
self.assertIn("parentChain", response.data["meta"])
self.assertIsInstance(response.data["meta"]["parentChain"], list)
# Should have 1 parent (the root)
parent_chain = response.data["meta"]["parentChain"]
self.assertEqual(len(parent_chain), 1)
self.assertEqual(parent_chain[0], f"{self.profile1.feed}#{root_post.post_id}")
def test_parent_chain_empty_for_root_post(self):
"""Test that parentChain is empty array for root posts."""
# Given: A root post (no parents)
post_url = f"{self.profile1.feed}#{self.original_post.post_id}"
# When: We request replies for the root post
response = self.client.get(self.replies_url, {"post": post_url})
# Then: parentChain should be empty
self.assertEqual(response.status_code, status.HTTP_200_OK)
parent_chain = response.data["meta"]["parentChain"]
self.assertEqual(len(parent_chain), 0)
self.assertEqual(parent_chain, [])
def test_parent_chain_order_in_meta(self):
"""Test that parentChain in meta is ordered from root to immediate parent."""
# Given: A deep chain: root -> reply_a -> reply_b -> reply_c
root = Post.objects.create(
profile=self.profile1,
post_id="2025-01-11T10:00:00+00:00",
content="Root",
)
reply_a = Post.objects.create(
profile=self.profile2,
post_id="2025-01-11T11:00:00+00:00",
content="Reply A",
reply_to=f"{self.profile1.feed}#{root.post_id}",
)
reply_b = Post.objects.create(
profile=self.profile3,
post_id="2025-01-11T12:00:00+00:00",
content="Reply B",
reply_to=f"{self.profile2.feed}#{reply_a.post_id}",
)
reply_c = Post.objects.create(
profile=self.profile1,
post_id="2025-01-11T13:00:00+00:00",
content="Reply C",
reply_to=f"{self.profile3.feed}#{reply_b.post_id}",
)
post_url = f"{self.profile1.feed}#{reply_c.post_id}"
# When: We request replies for reply_c
response = self.client.get(self.replies_url, {"post": post_url})
# Then: parentChain should be ordered: root -> reply_a -> reply_b
self.assertEqual(response.status_code, status.HTTP_200_OK)
parent_chain = response.data["meta"]["parentChain"]
self.assertEqual(len(parent_chain), 3)
self.assertEqual(parent_chain[0], f"{self.profile1.feed}#{root.post_id}")
self.assertEqual(parent_chain[1], f"{self.profile2.feed}#{reply_a.post_id}")
self.assertEqual(parent_chain[2], f"{self.profile3.feed}#{reply_b.post_id}")
def test_nodes_do_not_have_parent_chain(self):
"""Test that tree nodes do not include parentChain field."""
# Given: A post with replies
post_url = f"{self.profile1.feed}#{self.original_post.post_id}"
# When: We request replies
response = self.client.get(self.replies_url, {"post": post_url})
# Then: Data nodes should not have parentChain field
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data["data"]
for reply in data:
self.assertNotIn("parentChain", reply)
self.assertNotIn("parent_chain", reply)
# Only post, children, and moods
self.assertIn("post", reply)
self.assertIn("children", reply)
self.assertIn("moods", reply)
# Check nested replies also don't have parentChain
for child in reply["children"]:
self.assertNotIn("parentChain", child)
self.assertNotIn("parent_chain", child)