diff --git a/backend/src/graphql/resolvers/userResolvers.ts b/backend/src/graphql/resolvers/userResolvers.ts index 585d304..7b2698e 100644 --- a/backend/src/graphql/resolvers/userResolvers.ts +++ b/backend/src/graphql/resolvers/userResolvers.ts @@ -33,15 +33,20 @@ export function createUserResolvers(db: Db) { //Cover Image updateCoverImage: async ( _parent: unknown, - args: { image: string }, + args: { image: string | null }, context: { userId: string | null }, ) => { - if (!context.userId || !ObjectId.isValid(context.userId)) { + if (!context.userId) { throw new GraphQLError("You must be logged in"); } + if (!ObjectId.isValid(context.userId)) { + throw new GraphQLError("Invalid user ID"); + } + // Only accept JPEG, PNG and WebP if ( + args.image !== null && !/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test( args.image, ) @@ -50,7 +55,10 @@ export function createUserResolvers(db: Db) { } // Limit image size to approximately 2 MB - if (Buffer.byteLength(args.image, "utf8") > 2_800_000) { + if ( + args.image !== null && + Buffer.byteLength(args.image, "utf8") > 2_800_000 + ) { throw new GraphQLError("Image is too large"); } @@ -77,7 +85,7 @@ export function createUserResolvers(db: Db) { //Profile image updateProfileImage: async ( _parent: unknown, - args: { image: string }, + args: { image: string | null }, context: { userId: string | null }, ) => { if (!context.userId) { @@ -90,6 +98,7 @@ export function createUserResolvers(db: Db) { // Only accept JPEG, PNG and WebP images encoded as Base64 if ( + args.image !== null && !/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test( args.image, ) @@ -98,7 +107,10 @@ export function createUserResolvers(db: Db) { } // Limit the image to approximately 2 MB - if (Buffer.byteLength(args.image, "utf8") > 2_800_000) { + if ( + args.image !== null && + Buffer.byteLength(args.image, "utf8") > 2_800_000 + ) { throw new GraphQLError("Image is too large"); } diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts index 159655e..19a41f1 100644 --- a/backend/src/graphql/typeDefs.ts +++ b/backend/src/graphql/typeDefs.ts @@ -137,7 +137,7 @@ export const typeDefs = `#graphql removeArtworkFromGallery(galleryId: ID!, artworkId: Int!): Gallery! renameGallery(galleryId: ID!, title: String!): Gallery! deleteGallery(galleryId: ID!): ID! - updateProfileImage(image: String!): User! - updateCoverImage(image: String!): User! + updateProfileImage(image: String): User! + updateCoverImage(image: String): User! } `; diff --git a/backend/tests/profile-images.test.ts b/backend/tests/profile-images.test.ts new file mode 100644 index 0000000..6420400 --- /dev/null +++ b/backend/tests/profile-images.test.ts @@ -0,0 +1,110 @@ +import { ApolloServer } from "@apollo/server"; +import { ObjectId, type Db } from "mongodb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { typeDefs } from "../src/graphql/typeDefs.js"; +import { createResolvers } from "../src/graphql/resolvers/index.js"; + +describe("Profile image removal API", () => { + const userId = new ObjectId(); + let user: { + _id: ObjectId; + username: string; + profileImage: string | null; + coverImage: string | null; + }; + let server: ApolloServer<{ userId: string | null }>; + const update = vi.fn(); + + beforeEach(async () => { + user = { + _id: userId, + username: "alice", + profileImage: "profile.png", + coverImage: "cover.png", + }; + update.mockReset().mockImplementation(async (filter, change) => { + expect(filter).toEqual({ _id: userId }); + Object.assign(user, change.$set); + return { ...user }; + }); + const db = { + collection: () => ({ + findOne: async () => ({ ...user }), + findOneAndUpdate: update, + }), + } as unknown as Db; + server = new ApolloServer({ typeDefs, resolvers: createResolvers(db) }); + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it.each(["Profile", "Cover"])( + "persists removal of the %s image independently", + async (type) => { + const field = type === "Profile" ? "profileImage" : "coverImage"; + const response = await server.executeOperation( + { + query: `mutation Remove($image: String) { update${type}Image(image: $image) { profileImage coverImage } }`, + variables: { image: null }, + }, + { contextValue: { userId: userId.toString() } }, + ); + if (response.body.kind !== "single") + throw new Error("Expected single response"); + expect(response.body.singleResult.errors).toBeUndefined(); + expect(update).toHaveBeenCalledExactlyOnceWith( + { _id: userId }, + { $set: { [field]: null } }, + { returnDocument: "after" }, + ); + + // Read again through the API to verify saved state, independently of mutation data. + const reloaded = await server.executeOperation( + { query: "{ me { profileImage coverImage } }" }, + { contextValue: { userId: userId.toString() } }, + ); + if (reloaded.body.kind !== "single") + throw new Error("Expected single response"); + expect(reloaded.body.singleResult.errors).toBeUndefined(); + expect(reloaded.body.singleResult.data?.me).toEqual({ + profileImage: type === "Profile" ? null : "profile.png", + coverImage: type === "Cover" ? null : "cover.png", + }); + }, + ); + + it.each(["Profile", "Cover"])( + "requires authentication to remove the %s image", + async (type) => { + const response = await server.executeOperation( + { query: `mutation { update${type}Image(image: null) { id } }` }, + { contextValue: { userId: null } }, + ); + if (response.body.kind !== "single") + throw new Error("Expected single response"); + expect(response.body.singleResult.errors?.[0].message).toBe( + "You must be logged in", + ); + expect(update).not.toHaveBeenCalled(); + }, + ); + + it.each(["Profile", "Cover"])( + "still rejects invalid %s image uploads", + async (type) => { + const response = await server.executeOperation( + { query: `mutation { update${type}Image(image: "invalid") { id } }` }, + { contextValue: { userId: userId.toString() } }, + ); + if (response.body.kind !== "single") + throw new Error("Expected single response"); + expect(response.body.singleResult.errors?.[0].message).toBe( + "Invalid image format", + ); + expect(update).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/frontend/src/components/profile/ProfileHeader.tsx b/frontend/src/components/profile/ProfileHeader.tsx index 1c053f9..16d968f 100644 --- a/frontend/src/components/profile/ProfileHeader.tsx +++ b/frontend/src/components/profile/ProfileHeader.tsx @@ -1,5 +1,5 @@ -import { useRef, type ChangeEvent } from "react"; -import { Pencil } from "lucide-react"; +import { useEffect, useRef, useState, type ChangeEvent } from "react"; +import { Ellipsis, LogOut, Pencil, UserRound, X } from "lucide-react"; type ProfileHeaderProps = { username: string; @@ -7,29 +7,55 @@ type ProfileHeaderProps = { coverImage: string | null; profileImage: string | null; isLoadingImages: boolean; + isUpdatingImages: boolean; + imageError?: string; + onLogout: () => void; + onRemoveCoverImage: () => void; + onRemoveProfileImage: () => void; onCoverImageChange: (event: ChangeEvent) => void; onProfileImageChange: (event: ChangeEvent) => void; }; -// Displays the user's cover image, profile image and username +const actionClass = + "cursor-pointer rounded-md px-3 py-2 text-sm text-[#514943] transition-colors hover:bg-[#f4f1ee] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#80766f] disabled:opacity-50 dark:text-[#e6dfd6] dark:hover:bg-[#302e2a]"; + export default function ProfileHeader({ username, coverColor, coverImage, profileImage, isLoadingImages, + isUpdatingImages, + imageError, + onLogout, + onRemoveCoverImage, + onRemoveProfileImage, onCoverImageChange, onProfileImageChange, }: ProfileHeaderProps) { const coverInputRef = useRef(null); const profileInputRef = useRef(null); + const editDialogRef = useRef(null); + const optionsRef = useRef(null); + const optionsButtonRef = useRef(null); + const [optionsOpen, setOptionsOpen] = useState(false); + const busy = isUpdatingImages || isLoadingImages; + + useEffect(() => { + if (!optionsOpen) return; + function dismiss(event: PointerEvent) { + if (!optionsRef.current?.contains(event.target as Node)) + setOptionsOpen(false); + } + document.addEventListener("pointerdown", dismiss); + return () => document.removeEventListener("pointerdown", dismiss); + }, [optionsOpen]); + return ( <>
{isLoadingImages ? (
@@ -42,65 +68,203 @@ export default function ProfileHeader({ /> ) )} - - -
+
+

+ {username} +

+
{ + if (!event.currentTarget.contains(event.relatedTarget)) + setOptionsOpen(false); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + setOptionsOpen(false); + optionsButtonRef.current?.focus(); + } + }} + > + + {optionsOpen && ( +
+ + +
+ )} +
+
+
+ + optionsButtonRef.current?.focus()} + className="m-auto w-[calc(100%-2rem)] max-w-md rounded-2xl bg-white p-6 text-[#292624] shadow-xl backdrop:bg-black/40 dark:bg-[#242321] dark:text-[#f3f0ea]" + > +
+

+ Edit profile +

+ +
+ {(["profile", "cover"] as const).map((type) => { + const image = type === "profile" ? profileImage : coverImage; + const input = type === "profile" ? profileInputRef : coverInputRef; + return ( +
+ + {type === "profile" ? "Profile image" : "Cover image"} + +
+ + {image && ( + + )} +
+
+ ); + })} + {imageError && ( +

+ {imageError} +

+ )} +
+ +
+ - -

{username}

-
+ ); } diff --git a/frontend/src/hooks/profile/useUploadProfileImage.ts b/frontend/src/hooks/profile/useUploadProfileImage.ts index a14345b..a5001c8 100644 --- a/frontend/src/hooks/profile/useUploadProfileImage.ts +++ b/frontend/src/hooks/profile/useUploadProfileImage.ts @@ -17,14 +17,14 @@ type UpdateCoverImageData = { }; type ImageVariables = { - image: string; + image: string | null; }; const UPDATE_PROFILE_IMAGE: TypedDocumentNode< UpdateProfileImageData, ImageVariables > = gql` - mutation UpdateProfileImage($image: String!) { + mutation UpdateProfileImage($image: String) { updateProfileImage(image: $image) { profileImage } @@ -35,7 +35,7 @@ const UPDATE_COVER_IMAGE: TypedDocumentNode< UpdateCoverImageData, ImageVariables > = gql` - mutation UpdateCoverImage($image: String!) { + mutation UpdateCoverImage($image: String) { updateCoverImage(image: $image) { coverImage } @@ -45,6 +45,7 @@ const UPDATE_COVER_IMAGE: TypedDocumentNode< // Validates and uploads profile or cover images export function useUploadProfileImage(token: string | null) { const [isUploading, setIsUploading] = useState(false); + const [removalError, setRemovalError] = useState(""); const [updateProfileImage] = useMutation(UPDATE_PROFILE_IMAGE, { refetchQueries: ["GetProfileImages"], @@ -61,6 +62,7 @@ export function useUploadProfileImage(token: string | null) { type: ImageType, ): Promise { const file = event.target.files?.[0]; + event.target.value = ""; if (!file) return null; if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { @@ -126,5 +128,40 @@ export function useUploadProfileImage(token: string | null) { } } - return { uploadImage, isUploading }; + async function removeImage(type: ImageType): Promise { + setRemovalError(""); + setIsUploading(true); + + try { + if (!token) throw new Error("You must be logged in"); + + if (type === "profile") { + const { data } = await updateProfileImage({ + variables: { image: null }, + }); + if ( + !data?.updateProfileImage || + data.updateProfileImage.profileImage !== null + ) { + throw new Error("Profile image was not removed"); + } + } else { + const { data } = await updateCoverImage({ variables: { image: null } }); + if ( + !data?.updateCoverImage || + data.updateCoverImage.coverImage !== null + ) { + throw new Error("Cover image was not removed"); + } + } + return true; + } catch { + setRemovalError(`Could not remove ${type} image. Please try again.`); + return false; + } finally { + setIsUploading(false); + } + } + + return { uploadImage, removeImage, isUploading, removalError }; } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 1826dfe..41bac47 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -63,7 +63,8 @@ export default function ProfilePage() { const galleryCoverImages = useGalleryCoverImages(galleries); // Profile image hooks - const { uploadImage } = useUploadProfileImage(token); + const { uploadImage, removeImage, isUploading, removalError } = + useUploadProfileImage(token); const { profileImage, coverImage, isLoadingImages } = useProfileImages(token); @@ -107,20 +108,17 @@ export default function ProfilePage() { ← Back - - void removeImage("cover")} + onRemoveProfileImage={() => void removeImage("profile")} onCoverImageChange={handleCoverImageChange} onProfileImageChange={handleProfileImageChange} /> diff --git a/frontend/src/test/profileImageRemoval.test.tsx b/frontend/src/test/profileImageRemoval.test.tsx new file mode 100644 index 0000000..c1ea389 --- /dev/null +++ b/frontend/src/test/profileImageRemoval.test.tsx @@ -0,0 +1,177 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ApolloProvider } from "@apollo/client/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apolloClient } from "../lib/apolloClient"; +import { useAuthStore } from "../store/authStore"; +import { useProfileImages } from "../hooks/profile/useProfileImages"; +import { useUploadProfileImage } from "../hooks/profile/useUploadProfileImage"; +import ProfileHeader from "../components/profile/ProfileHeader"; + +const logout = vi.fn(); + +async function editProfile() { + await userEvent.click( + screen.getByRole("button", { name: "Profile options" }), + ); + await userEvent.click(screen.getByRole("button", { name: "Edit profile" })); +} + +function Profile() { + const images = useProfileImages("token"); + const { removeImage, isUploading, removalError } = + useUploadProfileImage("token"); + return ( + <> + {}} + onProfileImageChange={() => {}} + onRemoveCoverImage={() => void removeImage("cover")} + onRemoveProfileImage={() => void removeImage("profile")} + /> + + ); +} +const renderProfile = () => + render( + + + , + ); + +describe("Profile image removal", () => { + let saved: { profileImage: string | null; coverImage: string | null }; + let fail: boolean; + beforeEach(async () => { + await apolloClient.clearStore(); + useAuthStore + .getState() + .setAuth({ id: "alice", username: "Alice" }, "token"); + saved = { profileImage: "profile.png", coverImage: "cover.png" }; + fail = false; + logout.mockReset(); + vi.stubGlobal( + "fetch", + vi.fn(async (_uri, options) => { + const { operationName, variables } = JSON.parse(String(options?.body)); + let body; + if (operationName === "GetProfileImages") + body = { data: { me: { ...saved } } }; + else if (fail) body = { errors: [{ message: "Removal failed" }] }; + else { + expect(variables.image).toBeNull(); + if (operationName === "UpdateProfileImage") { + saved.profileImage = null; + body = { data: { updateProfileImage: { profileImage: null } } }; + } else if (operationName === "UpdateCoverImage") { + saved.coverImage = null; + body = { data: { updateCoverImage: { coverImage: null } } }; + } else throw new Error(`Unexpected operation ${operationName}`); + } + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + }); + }), + ); + }); + afterEach(async () => { + cleanup(); + await apolloClient.clearStore(); + useAuthStore.getState().logout(); + sessionStorage.clear(); + vi.unstubAllGlobals(); + }); + + it("keeps profile actions hidden until opened and logs out from the options", async () => { + renderProfile(); + expect( + screen.queryByRole("button", { name: "Log out" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Remove profile image" }), + ).not.toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: "Profile options" }), + ); + await userEvent.click(screen.getByRole("button", { name: "Log out" })); + expect(logout).toHaveBeenCalledOnce(); + }); + + it("closes the options with Escape and returns from editing", async () => { + renderProfile(); + await userEvent.click( + screen.getByRole("button", { name: "Profile options" }), + ); + await userEvent.keyboard("{Escape}"); + expect( + screen.queryByRole("button", { name: "Edit profile" }), + ).not.toBeInTheDocument(); + await editProfile(); + expect( + screen.getByRole("dialog", { name: "Edit profile" }), + ).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Done" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Remove cover image" }), + ).not.toBeInTheDocument(); + }); + + it.each(["profile", "cover"] as const)( + "removes the %s image and restores the default after reloading", + async (type) => { + const user = userEvent.setup(); + renderProfile(); + await editProfile(); + await user.click( + await screen.findByRole("button", { name: `Remove ${type} image` }), + ); + await waitFor(() => + expect( + screen.queryByRole("button", { name: `Remove ${type} image` }), + ).not.toBeInTheDocument(), + ); + expect( + screen.getByRole("button", { + name: `Remove ${type === "profile" ? "cover" : "profile"} image`, + }), + ).toBeInTheDocument(); + cleanup(); + await apolloClient.clearStore(); + renderProfile(); + await editProfile(); + await screen.findByRole("button", { + name: `Remove ${type === "profile" ? "cover" : "profile"} image`, + }); + expect( + screen.queryByRole("button", { name: `Remove ${type} image` }), + ).not.toBeInTheDocument(); + if (type === "profile") + expect( + screen.getByRole("button", { name: "Change profile image" }), + ).toHaveTextContent("Change profile image"); + else expect(screen.queryByAltText("Cover")).not.toBeInTheDocument(); + }, + ); + + it("shows an error and retains the image when removal fails", async () => { + fail = true; + renderProfile(); + await editProfile(); + await userEvent.click( + await screen.findByRole("button", { name: "Remove profile image" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not remove profile image", + ); + expect( + screen.getByRole("button", { name: "Remove profile image" }), + ).toBeEnabled(); + }); +});