diff --git a/.gitignore b/.gitignore index e43b0f9..f206674 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +/node_modules/ +.venv/ diff --git a/backend/custom_json_provider.py b/backend/custom_json_provider.py index 1e7f5bc..03b31dd 100644 --- a/backend/custom_json_provider.py +++ b/backend/custom_json_provider.py @@ -1,4 +1,5 @@ -from datetime import datetime +from datetime import datetime, timezone +now = datetime.now(timezone.utc) from flask.json.provider import DefaultJSONProvider diff --git a/backend/custom_json_provider_test.py b/backend/custom_json_provider_test.py index cc8f87e..94dc6d5 100644 --- a/backend/custom_json_provider_test.py +++ b/backend/custom_json_provider_test.py @@ -1,4 +1,5 @@ import datetime +from datetime import timezone import unittest from flask import Flask @@ -17,7 +18,7 @@ def test_datetime(self): hour=14, minute=15, second=16, - tzinfo=datetime.UTC, + tzinfo=timezone.utc, ) } ) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..a877bf2 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -1,4 +1,4 @@ -import datetime +from datetime import datetime, timezone from dataclasses import dataclass from typing import Any, Dict, List, Optional @@ -12,13 +12,13 @@ class Bloom: id: int sender: User content: str - sent_timestamp: datetime.datetime + sent_timestamp: datetime def add_bloom(*, sender: User, content: str) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] - now = datetime.datetime.now(tz=datetime.UTC) + now = datetime.now(timezone.utc) bloom_id = int(now.timestamp() * 1000000) with db_cursor() as cur: cur.execute( @@ -27,7 +27,7 @@ def add_bloom(*, sender: User, content: str) -> Bloom: bloom_id=bloom_id, sender_id=sender.id, content=content, - timestamp=datetime.datetime.now(datetime.UTC), + timestamp=datetime.now(timezone.utc), ), ) for hashtag in hashtags: diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314..759f32a 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -41,3 +41,10 @@ def get_inverse_followed_usernames(followee: User) -> List[str]: ) rows = cur.fetchall() return [row[0] for row in rows] + +def unfollow(follower: User, followee: User): + with db_cursor() as cur: + cur.execute( + "DELETE FROM follows WHERE follower = %s AND followee = %s", + (follower.id, followee.id), + ) diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..97426a8 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -1,6 +1,6 @@ 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, unfollow, get_followed_usernames, get_inverse_followed_usernames from data.users import ( UserRegistrationError, get_suggested_follows, @@ -150,6 +150,29 @@ def do_follow(): ) +@jwt_required() +def do_unfollow(): + type_check_error = verify_request_fields({"unfollow_username": str}) + if type_check_error is not None: + return type_check_error + + current_user = get_current_user() + + unfollow_username = request.json["unfollow_username"] + unfollow_user = get_user(unfollow_username) + if unfollow_user is None: + return make_response( + (f"Cannot unfollow {unfollow_username} - user does not exist", 404) + ) + + 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/main.py b/backend/main.py index 7ba155f..6c0d9a8 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, @@ -32,7 +33,7 @@ def main(): # Configure CORS to handle preflight requests CORS( app, - supports_credentials=True, + supports_credentials=False, resources={ r"/*": { "origins": "*", @@ -54,6 +55,7 @@ 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/backend/requirements.txt b/backend/requirements.txt index e03836c..0bbd4fd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ certifi==2025.4.26 cffi==1.17.1 charset-normalizer==3.4.2 click==8.1.8 -cryptography==44.0.1 +cryptography==43.0.3 Flask==3.1.0 flask-cors==5.0.1 Flask-JWT-Extended==4.7.1 diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f200..c2c0712 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -19,6 +19,7 @@ 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 || ""; @@ -29,8 +30,12 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { followButtonEl.setAttribute("data-username", profileData.username || ""); followButtonEl.hidden = profileData.is_self || profileData.is_following; followButtonEl.addEventListener("click", handleFollow); + unfollowButtonEl.setAttribute("data-username", profileData.username || ""); + unfollowButtonEl.hidden = profileData.is_self || !profileData.is_following; + unfollowButtonEl.addEventListener("click", handleUnfollow); if (!isLoggedIn) { followButtonEl.style.display = "none"; + unfollowButtonEl.style.display = "none"; } if (whoToFollow.length > 0) { @@ -66,4 +71,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 89d6b13..7c7ff62 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -187,6 +187,9 @@

Create your account

+
+ +

Who to follow

    diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..a9aeb1e 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -23,7 +23,7 @@ async function _apiRequest(endpoint, options = {}) { ...(token ? {Authorization: `Bearer ${token}`} : {}), }, mode: "cors", - credentials: "include", + // credentials: "include", }; const fetchOptions = {...defaultOptions, ...options}; @@ -184,7 +184,7 @@ async function getBloomsByHashtag(hashtag) { const blooms = await _apiRequest(endpoint); state.updateState({ hashtagBlooms: blooms, - currentHashtag: `#${tag}`, + currentHashtag: hashtag, }); return blooms; } catch (error) { @@ -261,8 +261,9 @@ async function followUser(username) { async function unfollowUser(username) { try { - const data = await _apiRequest(`/unfollow/${username}`, { + const data = await _apiRequest("/unfollow", { method: "POST", + body: JSON.stringify({ unfollow_username: username }), }); if (data.success) { diff --git a/front-end/tests/hashtag.spec.mjs b/front-end/tests/hashtag.spec.mjs new file mode 100644 index 0000000..2a513a9 --- /dev/null +++ b/front-end/tests/hashtag.spec.mjs @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; +import { loginAsSample } from "./test-utils.mjs"; + +test.describe("Hashtag Page", () => { + test("should not make infinite hashtag endpoint requests", async ({ + page, + }) => { + // 1. Given I am logged in + await loginAsSample(page); + + // 2. ARRANGE: Start listening for requests + const requests = []; + page.on("request", (request) => { + // Note: If the test fails with 0, check if the URL includes ":3000/hashtag/do" + // or if it should be an API path like "/api/hashtag/do" + if ( + request.url().includes(":3000/hashtag/do") && + request.resourceType() === "fetch" + ) { + requests.push(request); + } + }); + + // 3. ACT: Navigate to the hashtag + await page.goto("/#/hashtag/do"); + + // Wait to see if the bug triggers multiple requests + await page.waitForTimeout(200); + + // 4. ASSERT: Then the number of requests should be 1 + expect(requests.length).toEqual(1); + }); +}); diff --git a/front-end/tests/unfollow.spec.mjs b/front-end/tests/unfollow.spec.mjs new file mode 100644 index 0000000..d27a718 --- /dev/null +++ b/front-end/tests/unfollow.spec.mjs @@ -0,0 +1,30 @@ +import { test, expect } from "@playwright/test"; +import { loginAsSample, signUp } from "./test-utils.mjs"; + +test.describe("unfollow", () => { + test("allows unfollowing a user from their profile", async ({ page }) => { + await signUp(page, "sample"); + await signUp(page, "AnotherUser"); + + // Given a profile component AnotherUser + // And I am logged in as sample + await loginAsSample(page); + await page.goto("/#/profile/AnotherUser"); + // And sample is following AS + await page.click('[data-action="follow"]'); + + // When I view the profile component for AnotherUser + // Then I should see a button labeled "Unfollow" + const unfollowButton = page.locator('[data-action="unfollow"]'); + await expect(unfollowButton).toBeVisible(); + + // When I click the "Unfollow" button + await unfollowButton.click(); + + // Then I should no longer be following AnotherUser + const followerCount = page.locator("[data-follower-count]"); + await expect(followerCount).toHaveText("0"); + // And the unfollow button is not visible + await expect(unfollowButton).toBeHidden(); + }); +}) diff --git a/front-end/views/hashtag.mjs b/front-end/views/hashtag.mjs index 7b7e996..92419cb 100644 --- a/front-end/views/hashtag.mjs +++ b/front-end/views/hashtag.mjs @@ -14,10 +14,12 @@ import {createHeading} from "../components/heading.mjs"; // Hashtag view: show all tweets containing this tag -function hashtagView(hashtag) { +async function hashtagView(hashtag) { destroy(); - apiService.getBloomsByHashtag(hashtag); + if (hashtag !== state.currentHashtag) { + await apiService.getBloomsByHashtag(hashtag); + } renderOne( state.isLoggedIn, diff --git a/front-end/views/profile.mjs b/front-end/views/profile.mjs index dd2b92a..35742d1 100644 --- a/front-end/views/profile.mjs +++ b/front-end/views/profile.mjs @@ -9,7 +9,7 @@ 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 diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file