Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.DS_Store
/node_modules/
.venv/
3 changes: 2 additions & 1 deletion backend/custom_json_provider.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
from flask.json.provider import DefaultJSONProvider


Expand Down
3 changes: 2 additions & 1 deletion backend/custom_json_provider_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
from datetime import timezone
import unittest

from flask import Flask
Expand All @@ -17,7 +18,7 @@ def test_datetime(self):
hour=14,
minute=15,
second=16,
tzinfo=datetime.UTC,
tzinfo=timezone.utc,
)
}
)
Expand Down
8 changes: 4 additions & 4 deletions backend/data/blooms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import datetime
from datetime import datetime, timezone

from dataclasses import dataclass
from typing import Any, Dict, List, Optional
Expand All @@ -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(
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions backend/data/follows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
25 changes: 24 additions & 1 deletion backend/endpoints.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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})
Expand Down
4 changes: 3 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from data.users import lookup_user
from endpoints import (
do_follow,
do_unfollow,
get_bloom,
hashtag,
home_timeline,
Expand Down Expand Up @@ -32,7 +33,7 @@ def main():
# Configure CORS to handle preflight requests
CORS(
app,
supports_credentials=True,
supports_credentials=False,
resources={
r"/*": {
"origins": "*",
Expand All @@ -54,6 +55,7 @@ def main():
app.add_url_rule("/profile", view_func=self_profile)
app.add_url_rule("/profile/<profile_username>", 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/<limit_str>", view_func=suggested_follows)

app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom)
Expand Down
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion front-end/components/profile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
3 changes: 3 additions & 0 deletions front-end/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ <h1 id="signup-heading" class="signup__title">Create your account</h1>
<div class="profile__actions">
<button type="button" data-action="follow">Follow</button>
</div>
<div class="profile__actions">
<button type="button" data-action="unfollow">Unfollow</button>
</div>
<div class="profile__who-to-follow">
<h4>Who to follow</h4>
<ul data-who-to-follow>
Expand Down
7 changes: 4 additions & 3 deletions front-end/lib/api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async function _apiRequest(endpoint, options = {}) {
...(token ? {Authorization: `Bearer ${token}`} : {}),
},
mode: "cors",
credentials: "include",
// credentials: "include",
};

const fetchOptions = {...defaultOptions, ...options};
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions front-end/tests/hashtag.spec.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions front-end/tests/unfollow.spec.mjs
Original file line number Diff line number Diff line change
@@ -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();
});
})
6 changes: 4 additions & 2 deletions front-end/views/hashtag.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion front-end/views/profile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}