mirror of
https://github.com/tanrax/org-social-relay
synced 2026-01-11 07:23:33 +01:00
499 lines
20 KiB
Python
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)
|