diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314e..43c566b7 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -21,6 +21,17 @@ def follow(follower: User, followee: User): pass +def unfollow(follower: User, followee: User): + with db_cursor() as cur: + cur.execute( + "DELETE FROM follows WHERE follower = %(follower_id)s AND followee = %(followee_id)s", + dict( + follower_id=follower.id, + followee_id=followee.id, + ), + ) + + def get_followed_usernames(follower: User) -> List[str]: """get_followed_usernames returns a list of usernames followee follows.""" with db_cursor() as cur: diff --git a/backend/data/follows_test.py b/backend/data/follows_test.py new file mode 100644 index 00000000..cd61e02d --- /dev/null +++ b/backend/data/follows_test.py @@ -0,0 +1,24 @@ +import unittest +from unittest.mock import MagicMock, patch + +from data.follows import unfollow + + +class TestUnfollow(unittest.TestCase): + @patch("data.follows.db_cursor") + def test_unfollow_deletes_relationship(self, db_cursor): + cursor = MagicMock() + db_cursor.return_value.__enter__.return_value = cursor + follower = MagicMock(id=1) + followee = MagicMock(id=2) + + unfollow(follower, followee) + + cursor.execute.assert_called_once_with( + "DELETE FROM follows WHERE follower = %(follower_id)s AND followee = %(followee_id)s", + {"follower_id": 1, "followee_id": 2}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..79aa297c 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -1,6 +1,11 @@ from typing import Dict, Union from data import blooms -from data.follows import follow, get_followed_usernames, get_inverse_followed_usernames +from data.follows import ( + follow, + get_followed_usernames, + get_inverse_followed_usernames, + unfollow, +) from data.users import ( UserRegistrationError, get_suggested_follows, @@ -150,6 +155,23 @@ def do_follow(): ) +@jwt_required() +def do_unfollow(username): + unfollow_user = get_user(username) + if unfollow_user is None: + return make_response( + (f"Cannot unfollow {username} - user does not exist", 404) + ) + + current_user = get_current_user() + unfollow(current_user, unfollow_user) + return jsonify( + { + "success": True, + } + ) + + @jwt_required() def send_bloom(): type_check_error = verify_request_fields({"content": str}) diff --git a/backend/endpoints_test.py b/backend/endpoints_test.py new file mode 100644 index 00000000..bc3d4778 --- /dev/null +++ b/backend/endpoints_test.py @@ -0,0 +1,51 @@ +import unittest +from unittest.mock import patch + +from flask import Flask +from flask_jwt_extended import JWTManager, create_access_token + +from endpoints import do_unfollow + + +class TestDoUnfollow(unittest.TestCase): + def setUp(self): + self.app = Flask("Dummy") + self.app.config["JWT_SECRET_KEY"] = "test-secret-at-least-32-bytes-long" + JWTManager(self.app) + self.app.add_url_rule( + "/unfollow/", methods=["POST"], view_func=do_unfollow + ) + with self.app.app_context(): + access_token = create_access_token(identity="test-user") + self.headers = {"Authorization": f"Bearer {access_token}"} + + @patch("endpoints.get_current_user") + @patch("endpoints.get_user") + @patch("endpoints.unfollow") + def test_unfollow_user(self, unfollow, get_user, get_current_user): + current_user = get_current_user.return_value + unfollow_user = get_user.return_value + + response = self.app.test_client().post( + "/unfollow/other-user", headers=self.headers + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, {"success": True}) + get_user.assert_called_once_with("other-user") + unfollow.assert_called_once_with(current_user, unfollow_user) + + @patch("endpoints.get_user", return_value=None) + @patch("endpoints.unfollow") + def test_unfollow_unknown_user_returns_404(self, unfollow, get_user): + response = self.app.test_client().post( + "/unfollow/unknown-user", headers=self.headers + ) + + self.assertEqual(response.status_code, 404) + get_user.assert_called_once_with("unknown-user") + unfollow.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/main.py b/backend/main.py index 7ba155fa..c5b092e1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from data.users import lookup_user from endpoints import ( do_follow, + do_unfollow, get_bloom, hashtag, home_timeline, @@ -54,6 +55,9 @@ def main(): app.add_url_rule("/profile", view_func=self_profile) app.add_url_rule("/profile/", view_func=other_profile) app.add_url_rule("/follow", methods=["POST"], view_func=do_follow) + app.add_url_rule( + "/unfollow/", methods=["POST"], view_func=do_unfollow + ) app.add_url_rule("/suggested-follows/", view_func=suggested_follows) app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..ef0c441a 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -37,7 +37,7 @@ const createBloom = (template, bloom) => { function _formatHashtags(text) { if (!text) return text; return text.replace( - /\B#[^#]+/g, + /#[A-Za-z0-9_]+/g, (match) => `${match}` ); } diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f2009..508b3b93 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -19,6 +19,9 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { ); const followerCountEl = profileElement.querySelector("[data-follower-count]"); const followButtonEl = profileElement.querySelector("[data-action='follow']"); + const unfollowButtonEl = profileElement.querySelector( + "[data-action='unfollow']" + ); const whoToFollowContainer = profileElement.querySelector(".profile__who-to-follow"); // Populate with data usernameEl.querySelector("h2").textContent = profileData.username || ""; @@ -27,11 +30,12 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { followerCountEl.textContent = profileData.followers?.length || 0; followingCountEl.textContent = profileData.follows?.length || 0; followButtonEl.setAttribute("data-username", profileData.username || ""); - followButtonEl.hidden = profileData.is_self || profileData.is_following; + unfollowButtonEl.setAttribute("data-username", profileData.username || ""); + const showProfileActions = isLoggedIn && !profileData.is_self; + followButtonEl.hidden = !showProfileActions || profileData.is_following; + unfollowButtonEl.hidden = !showProfileActions || !profileData.is_following; followButtonEl.addEventListener("click", handleFollow); - if (!isLoggedIn) { - followButtonEl.style.display = "none"; - } + unfollowButtonEl.addEventListener("click", handleUnfollow); if (whoToFollow.length > 0) { const whoToFollowList = whoToFollowContainer.querySelector("[data-who-to-follow]"); @@ -66,4 +70,13 @@ async function handleFollow(event) { await apiService.getWhoToFollow(); } -export {createProfile, handleFollow}; +async function handleUnfollow(event) { + const button = event.target; + const username = button.getAttribute("data-username"); + if (!username) return; + + await apiService.unfollowUser(username); + await apiService.getWhoToFollow(); +} + +export {createProfile, handleFollow, handleUnfollow}; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..1b83f4c1 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -186,6 +186,7 @@

Create your account

+

Who to follow

diff --git a/front-end/tests/hashtag.spec.mjs b/front-end/tests/hashtag.spec.mjs new file mode 100644 index 00000000..5010c242 --- /dev/null +++ b/front-end/tests/hashtag.spec.mjs @@ -0,0 +1,44 @@ +import {test, expect} from "@playwright/test"; + +async function renderBloom(page, content) { + await page.goto("/"); + await page.evaluate(async (bloomContent) => { + const {createBloom} = await import("/components/bloom.mjs"); + const bloom = createBloom("bloom-template", { + id: 1, + sender: "sample", + content: bloomContent, + sent_timestamp: new Date().toISOString(), + }); + document.getElementById("timeline-container").append(bloom); + }, content); +} + +test.describe("Hashtag links", () => { + test("links a hashtag in the middle of a sentence", async ({page}) => { + // Given a bloom has a hashtag in the middle of a sentence + await renderBloom(page, "Let's get some #SwizBiz love!!"); + + // Then only the hashtag is included in the link + const hashtagLink = page.locator( + '[data-content] a[href="/hashtag/SwizBiz"]' + ); + await expect(hashtagLink).toHaveText("#SwizBiz"); + }); + + test("does not include punctuation or trailing text in a hashtag link", async ({ + page, + }) => { + // Given a bloom has punctuation and text after a hashtag + await renderBloom(page, "Testing #hashtag_link, with trailing text"); + + // Then only the hashtag is included in the link + const hashtagLink = page.locator( + '[data-content] a[href="/hashtag/hashtag_link"]' + ); + await expect(hashtagLink).toHaveText("#hashtag_link"); + await expect(page.locator("[data-content]")).toHaveText( + "Testing #hashtag_link, with trailing text" + ); + }); +}); diff --git a/front-end/tests/unfollow.spec.mjs b/front-end/tests/unfollow.spec.mjs new file mode 100644 index 00000000..19cb64ed --- /dev/null +++ b/front-end/tests/unfollow.spec.mjs @@ -0,0 +1,109 @@ +import {test, expect} from "@playwright/test"; + +const currentUsername = "sample"; +const profileUsername = "OtherUser"; + +async function mockApi(page) { + let isFollowing = false; + + await page.route("http://localhost:3000/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + + if (path === "/login") { + await route.fulfill({json: {success: true, token: "test-token"}}); + return; + } + if (path === "/follow") { + isFollowing = true; + await route.fulfill({json: {success: true}}); + return; + } + if (path === `/unfollow/${profileUsername}`) { + isFollowing = false; + await route.fulfill({json: {success: true}}); + return; + } + if (path === `/profile/${profileUsername}`) { + await route.fulfill({ + json: { + username: profileUsername, + recent_blooms: [], + follows: [], + followers: isFollowing ? [currentUsername] : [], + is_following: isFollowing, + is_self: false, + total_blooms: 0, + }, + }); + return; + } + if (path === `/profile/${currentUsername}`) { + await route.fulfill({ + json: { + username: currentUsername, + recent_blooms: [], + follows: isFollowing ? [profileUsername] : [], + followers: [], + is_following: false, + is_self: true, + total_blooms: 0, + }, + }); + return; + } + if (path === "/home" || path.startsWith("/suggested-follows/")) { + await route.fulfill({json: []}); + return; + } + + await route.fulfill({status: 404, json: {success: false}}); + }); +} + +async function loginAndOpenProfile(page) { + await page.goto("/"); + await page.fill('[data-form="login"] input[name="username"]', currentUsername); + await page.fill('[data-form="login"] input[name="password"]', "sosecret"); + await page.click('[data-form="login"] [data-submit]'); + await page.goto(`/#/profile/${profileUsername}`); +} + +test.describe("Unfollow", () => { + test.beforeEach(async ({page}) => { + await mockApi(page); + }); + + test("toggles follow and unfollow actions", async ({page}) => { + await loginAndOpenProfile(page); + + const followButton = page.locator( + "#profile-container [data-action='follow']" + ); + const unfollowButton = page.locator( + "#profile-container [data-action='unfollow']" + ); + + await expect(followButton).toBeVisible(); + await expect(unfollowButton).toBeHidden(); + + await followButton.click(); + await expect(unfollowButton).toBeVisible(); + await expect(followButton).toBeHidden(); + + await unfollowButton.click(); + await expect(followButton).toBeVisible(); + await expect(unfollowButton).toBeHidden(); + }); + + test("unfollowing a user who is not followed succeeds", async ({page}) => { + await loginAndOpenProfile(page); + + const response = await page.evaluate(async (username) => { + const {apiService} = await import("/lib/api.mjs"); + return apiService.unfollowUser(username); + }, profileUsername); + + expect(response).toEqual({success: true}); + }); +}); diff --git a/front-end/views/profile.mjs b/front-end/views/profile.mjs index dd2b92af..452b3ab0 100644 --- a/front-end/views/profile.mjs +++ b/front-end/views/profile.mjs @@ -9,7 +9,11 @@ import { } from "../index.mjs"; import {createLogin, handleLogin} from "../components/login.mjs"; import {createLogout, handleLogout} from "../components/logout.mjs"; -import {createProfile, handleFollow} from "../components/profile.mjs"; +import { + createProfile, + handleFollow, + handleUnfollow, +} from "../components/profile.mjs"; import {createBloom} from "../components/bloom.mjs"; // Profile view - just this person's blooms and their profile