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) {