diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..3b5f7fe 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -157,6 +157,10 @@ def send_bloom(): return type_check_error user = get_current_user() + # Check server-side length + content=request.json["content"] + if len(content)>280 : + return jsonify({"success": False, "error": "Bloom must be 280 characters or less"}), 400 blooms.add_bloom(sender=user, content=request.json["content"]) diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..166ab84 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -36,9 +36,14 @@ const createBloom = (template, bloom) => { function _formatHashtags(text) { if (!text) return text; - return text.replace( - /\B#[^#]+/g, - (match) => `${match}` + + // Normalize newlines and tabs to spaces + const normalizedText = text.replace(/[\r\n\t]+/g, " "); + + return normalizedText.replace( + // Updated regex to correctly handle multiple hashtags with letters, numbers, and underscores + /#([a-zA-Z0-9_]+)/g, + (match) => `${match}`, ); } diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..7ada5c1 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -1,6 +1,7 @@ import {state} from "../index.mjs"; import {handleErrorDialog} from "../components/error.mjs"; + // === ABOUT THE STATE // state gives you these two functions only // updateState({stateKey: newValues}) @@ -194,10 +195,16 @@ async function getBloomsByHashtag(hashtag) { } async function postBloom(content) { + // Check client-side length first + if (content.length>280){ + handleErrorDialog(new Error("Bloom must be 280 characters or less")); + return { success: false }; + + } try { const data = await _apiRequest("/bloom", { method: "POST", - body: JSON.stringify({content}), + body: JSON.stringify({ content }), }); if (data.success) { @@ -208,7 +215,7 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } diff --git a/front-end/tests/bloom-length.spec.js b/front-end/tests/bloom-length.spec.js new file mode 100644 index 0000000..c4f71da --- /dev/null +++ b/front-end/tests/bloom-length.spec.js @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; +import { loginAsSample } from "./test-utils"; + +test("server should reject blooms longer than 280 characters", async ({ + page, +}) => { + + await loginAsSample(page); + + const longBloom = "A".repeat(281); + + const result = await page.evaluate(async (content) => { + const res = await fetch("/bloom", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ content }), + }); + + if (!res.ok) { + try { + return await res.json(); + } catch { + return { success: false }; + } + } + + return await res.json(); + }, longBloom); + + expect(result.success).toBe(false); +}); diff --git a/front-end/tests/hashtag.spec.mjs b/front-end/tests/hashtag.spec.mjs new file mode 100644 index 0000000..a816810 --- /dev/null +++ b/front-end/tests/hashtag.spec.mjs @@ -0,0 +1,24 @@ +import { test, expect } from "@playwright/test"; + +test("should not make infinite hashtag endpoint requests", async ({ page }) => { + // ===== ARRANGE + const requests = []; + page.on("request", (request) => { + if ( + request.url().includes(":3000/hashtag/do") && + request.resourceType() === "fetch" + ) { + requests.push(request); + } + }); + // ====== ACT + // When I navigate to the hashtag + await page.goto("/#/hashtag/do"); + // And I wait a reasonable time for any additional requests + await page.waitForTimeout(200); + + // ====== ASSERT + // Then the number of requests should be 1 + console.log("Number of requests:", requests.length); + expect(requests.length).toEqual(1); +}); diff --git a/front-end/tests/profile-logout-login.spec.mjs b/front-end/tests/profile-logout-login.spec.mjs new file mode 100644 index 0000000..ff49ab1 --- /dev/null +++ b/front-end/tests/profile-logout-login.spec.mjs @@ -0,0 +1,28 @@ +import { test, expect } from "@playwright/test"; +import { loginAsSample, logout } from "./test-utils.mjs"; + +test("can visit another user's profile after logout and re-login", async ({ + page, +}) => { + // Given I am logged in + await loginAsSample(page); + + // And I visit another user's profile + await page.goto("/#/profile/AS"); + + // And I log out + await logout(page); + + // When I log in again + await loginAsSample(page); + + // And I visit the same profile again + await page.goto("/#/profile/AS"); + + // Then I should see the profile view (NOT a server error) + await expect(page.locator("#profile-container")).toBeVisible(); + + await expect(page.locator("body")).not.toContainText( + "Server does not support this operation", + ); +}); diff --git a/front-end/views/hashtag.mjs b/front-end/views/hashtag.mjs index 7b7e996..f15918d 100644 --- a/front-end/views/hashtag.mjs +++ b/front-end/views/hashtag.mjs @@ -17,14 +17,21 @@ import {createHeading} from "../components/heading.mjs"; function hashtagView(hashtag) { destroy(); - apiService.getBloomsByHashtag(hashtag); + // Normalize the hashtag to always include a leading '#' and + // prevent infinite API calls by only fetching if the hashtag has changed + const normalizedHashtag = hashtag.startsWith("#") ? hashtag : `#${hashtag}`; + if (state.currentHashtag !== normalizedHashtag) { + state.currentHashtag = normalizedHashtag; + apiService.getBloomsByHashtag(normalizedHashtag); + } renderOne( state.isLoggedIn, getLogoutContainer(), "logout-template", - createLogout + createLogout, ); + document .querySelector("[data-action='logout']") ?.addEventListener("click", handleLogout); @@ -32,23 +39,23 @@ function hashtagView(hashtag) { state.isLoggedIn, getLoginContainer(), "login-template", - createLogin + createLogin, ); document - .querySelector("[data-action='login']") - ?.addEventListener("click", handleLogin); + .querySelector("[data-form='login']") + ?.addEventListener("submit", handleLogin); renderOne( state.currentHashtag, getHeadingContainer(), "heading-template", - createHeading + createHeading, ); renderEach( state.hashtagBlooms || [], getTimelineContainer(), "bloom-template", - createBloom + createBloom, ); } diff --git a/front-end/views/profile.mjs b/front-end/views/profile.mjs index dd2b92a..31139a9 100644 --- a/front-end/views/profile.mjs +++ b/front-end/views/profile.mjs @@ -39,8 +39,8 @@ function profileView(username) { createLogin ); document - .querySelector("[data-action='login']") - ?.addEventListener("click", handleLogin); + .querySelector("[data-form='login']") + ?.addEventListener("submit", handleLogin); const profileData = state.profiles.find((p) => p.username === username); if (profileData) {