diff --git a/quotevote-backend/__tests__/unit/authentication.test.ts b/quotevote-backend/__tests__/unit/authentication.test.ts index eb0da4c1..c3b92689 100644 --- a/quotevote-backend/__tests__/unit/authentication.test.ts +++ b/quotevote-backend/__tests__/unit/authentication.test.ts @@ -158,8 +158,12 @@ describe('Authentication Utils', () => { const mockUser = { _id: 'mockId', username: 'testuser', + name: 'Test User', email: 'test@example.com', admin: false, + accountStatus: 'active', + avatar: { topType: 'ShortHairShortFlat', hairColor: 'Brown' }, + bio: 'Hello', comparePassword: jest.fn().mockResolvedValue(true), }; (User.findOne as jest.Mock).mockResolvedValue(mockUser); @@ -170,7 +174,12 @@ describe('Authentication Utils', () => { expect(jwt.sign).toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'token', - refreshToken: 'token' + refreshToken: 'token', + user: expect.objectContaining({ + username: 'testuser', + avatar: { topType: 'ShortHairShortFlat', hairColor: 'Brown' }, + bio: 'Hello', + }), })); }); }); diff --git a/quotevote-backend/__tests__/unit/resolvers/userResolver.test.ts b/quotevote-backend/__tests__/unit/resolvers/userResolver.test.ts index ed23cbb7..cd2988a9 100644 --- a/quotevote-backend/__tests__/unit/resolvers/userResolver.test.ts +++ b/quotevote-backend/__tests__/unit/resolvers/userResolver.test.ts @@ -1,9 +1,29 @@ import mongoose from 'mongoose'; +import { GraphQLError } from 'graphql'; import { userResolver } from '~/data/resolvers/userResolver'; import User from '~/data/models/User'; +import type { GraphQLContext } from '~/types/graphql'; jest.mock('~/data/models/User'); +const actorId = '60d5ec49ad414d7a8d5464a0'; +const otherId = '60d5ec49ad414d7a8d5464a1'; + +function mockContext(overrides: Partial> = {}): GraphQLContext { + return { + req: {} as GraphQLContext['req'], + res: {} as GraphQLContext['res'], + pubsub: {} as GraphQLContext['pubsub'], + user: { + _id: actorId, + username: 'alice', + email: 'alice@example.com', + admin: false, + ...overrides, + } as NonNullable, + }; +} + describe('userResolver', () => { beforeEach(() => { jest.clearAllMocks(); @@ -63,4 +83,145 @@ describe('userResolver', () => { ]); }); }); + + describe('Mutation.updateUser', () => { + it('requires authentication', async () => { + await expect( + userResolver.Mutation.updateUser( + null, + { user: { _id: actorId, bio: 'Hello' } }, + { ...mockContext(), user: null } + ) + ).rejects.toThrow(GraphQLError); + }); + + it('updates own bio as plain text', async () => { + (User.findByIdAndUpdate as jest.Mock).mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(actorId), + username: 'alice', + bio: 'Thoughtful dialogue', + }), + }); + + const result = await userResolver.Mutation.updateUser( + null, + { user: { _id: actorId, bio: ' Thoughtful dialogue ' } }, + mockContext() + ); + + expect(User.findByIdAndUpdate).toHaveBeenCalledWith( + actorId, + { $set: { bio: 'Thoughtful dialogue' } }, + { new: true } + ); + expect(result.bio).toBe('Thoughtful dialogue'); + }); + + it('rejects HTML in bio', async () => { + await expect( + userResolver.Mutation.updateUser( + null, + { user: { _id: actorId, bio: 'nope' } }, + mockContext() + ) + ).rejects.toThrow(/plain text/); + expect(User.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it('forbids non-admin from updating another user', async () => { + await expect( + userResolver.Mutation.updateUser( + null, + { user: { _id: otherId, bio: 'Nope' } }, + mockContext({ admin: false }) + ) + ).rejects.toThrow(/Not authorized/); + }); + + it('allows admin to update contributorBadge on another user', async () => { + (User.findByIdAndUpdate as jest.Mock).mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(otherId), + username: 'bob', + contributorBadge: true, + }), + }); + + const result = await userResolver.Mutation.updateUser( + null, + { user: { _id: otherId, contributorBadge: true } }, + mockContext({ admin: true }) + ); + + expect(User.findByIdAndUpdate).toHaveBeenCalledWith( + otherId, + { $set: { contributorBadge: true } }, + { new: true } + ); + expect(result.contributorBadge).toBe(true); + }); + }); + + describe('Mutation.updateUserAvatar', () => { + const avatarQualities = { + topType: 'LongHairStraight', + hairColor: 'Brown', + clotheType: 'Hoodie', + }; + + it('requires authentication', async () => { + await expect( + userResolver.Mutation.updateUserAvatar( + null, + { user_id: actorId, avatarQualities }, + { ...mockContext(), user: null } + ) + ).rejects.toThrow(GraphQLError); + }); + + it('updates own avatar qualities', async () => { + (User.findByIdAndUpdate as jest.Mock).mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(actorId), + username: 'alice', + avatar: avatarQualities, + }), + }); + + const result = await userResolver.Mutation.updateUserAvatar( + null, + { user_id: actorId, avatarQualities }, + mockContext() + ); + + expect(User.findByIdAndUpdate).toHaveBeenCalledWith( + actorId, + { $set: { avatar: avatarQualities } }, + { new: true } + ); + expect(result.avatar).toEqual(avatarQualities); + }); + + it('forbids updating another user avatar', async () => { + await expect( + userResolver.Mutation.updateUserAvatar( + null, + { user_id: otherId, avatarQualities }, + mockContext({ admin: false }) + ) + ).rejects.toThrow(/Not authorized/); + expect(User.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it('rejects non-object avatarQualities', async () => { + await expect( + userResolver.Mutation.updateUserAvatar( + null, + { user_id: actorId, avatarQualities: null }, + mockContext() + ) + ).rejects.toThrow(/avatarQualities/); + }); + }); }); diff --git a/quotevote-backend/__tests__/unit/utils/bioValidation.test.ts b/quotevote-backend/__tests__/unit/utils/bioValidation.test.ts new file mode 100644 index 00000000..8f6ca268 --- /dev/null +++ b/quotevote-backend/__tests__/unit/utils/bioValidation.test.ts @@ -0,0 +1,33 @@ +import { normalizeBio, BIO_MAX_LENGTH } from '~/data/utils/bioValidation'; + +describe('bioValidation', () => { + describe('normalizeBio', () => { + it('trims whitespace', () => { + expect(normalizeBio(' hello world ')).toBe('hello world'); + }); + + it('returns empty string for nullish or blank input', () => { + expect(normalizeBio(null)).toBe(''); + expect(normalizeBio(undefined)).toBe(''); + expect(normalizeBio(' ')).toBe(''); + }); + + it('accepts plain text within the max length', () => { + const bio = 'a'.repeat(BIO_MAX_LENGTH); + expect(normalizeBio(bio)).toBe(bio); + }); + + it('rejects text longer than the max length', () => { + expect(() => normalizeBio('a'.repeat(BIO_MAX_LENGTH + 1))).toThrow( + `About must be ${BIO_MAX_LENGTH} characters or fewer` + ); + }); + + it('rejects HTML markup', () => { + expect(() => normalizeBio('Hello ')).toThrow( + 'About must be plain text without HTML' + ); + expect(() => normalizeBio('bold')).toThrow('About must be plain text without HTML'); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/utils/serializeDate.test.ts b/quotevote-backend/__tests__/unit/utils/serializeDate.test.ts new file mode 100644 index 00000000..76460e04 --- /dev/null +++ b/quotevote-backend/__tests__/unit/utils/serializeDate.test.ts @@ -0,0 +1,30 @@ +import { toIsoDateString } from '~/data/utils/serializeDate'; + +describe('toIsoDateString', () => { + it('serializes Date instances to ISO strings', () => { + expect(toIsoDateString(new Date('2024-01-15T12:00:00.000Z'))).toBe( + '2024-01-15T12:00:00.000Z' + ); + }); + + it('serializes millisecond epoch numbers to ISO strings', () => { + const ms = Date.UTC(2024, 0, 15); + expect(toIsoDateString(ms)).toBe(new Date(ms).toISOString()); + }); + + it('treats small epoch numbers as seconds', () => { + expect(toIsoDateString(1705276800)).toBe('2024-01-15T00:00:00.000Z'); + }); + + it('serializes numeric timestamp strings', () => { + const ms = Date.UTC(2024, 0, 15); + expect(toIsoDateString(String(ms))).toBe(new Date(ms).toISOString()); + }); + + it('returns empty string for invalid values', () => { + expect(toIsoDateString(null)).toBe(''); + expect(toIsoDateString(undefined)).toBe(''); + expect(toIsoDateString('not-a-date')).toBe(''); + expect(toIsoDateString(new Date('invalid'))).toBe(''); + }); +}); diff --git a/quotevote-backend/app/data/inputs/UserInput.ts b/quotevote-backend/app/data/inputs/UserInput.ts index 307ea1e3..a8425dce 100644 --- a/quotevote-backend/app/data/inputs/UserInput.ts +++ b/quotevote-backend/app/data/inputs/UserInput.ts @@ -10,6 +10,7 @@ export const UserInput = new GraphQLInputObjectType({ password: { type: GraphQLString }, quotes: { type: new GraphQLList(GraphQLString) }, avatar: { type: GraphQLString }, + bio: { type: GraphQLString }, contributorBadge: { type: GraphQLBoolean }, themePreference: { type: GraphQLString }, }, diff --git a/quotevote-backend/app/data/resolvers/userResolver.ts b/quotevote-backend/app/data/resolvers/userResolver.ts index 37e30304..b2d4fc2d 100644 --- a/quotevote-backend/app/data/resolvers/userResolver.ts +++ b/quotevote-backend/app/data/resolvers/userResolver.ts @@ -1,5 +1,44 @@ +import * as bcrypt from 'bcryptjs'; +import { GraphQLError } from 'graphql'; import User from '../models/User'; +import { normalizeBio } from '../utils/bioValidation'; import type * as Common from '~/types/common'; +import type { GraphQLContext } from '~/types/graphql'; + +type UpdateUserInput = { + _id: string; + name?: string | null; + username?: string | null; + email?: string | null; + password?: string | null; + avatar?: string | null; + bio?: string | null; + contributorBadge?: boolean | null; + themePreference?: string | null; +}; + +function toPublicUser(user: { + _id: { toString(): string } | string; + reputation?: Common.Reputation | null; + [key: string]: unknown; +}): Common.User { + const userId = typeof user._id === 'string' ? user._id : user._id.toString(); + return { + ...user, + _id: userId, + reputation: user.reputation + ? { ...user.reputation, _id: user.reputation._id ?? userId } + : undefined, + } as unknown as Common.User; +} + +function asPublicUserDoc(user: unknown): Common.User { + return toPublicUser(user as { + _id: { toString(): string } | string; + reputation?: Common.Reputation | null; + [key: string]: unknown; + }); +} export const userResolver = { Query: { @@ -15,23 +54,12 @@ export const userResolver = { accountStatus: 'active', }) .select( - '_id name username avatar contributorBadge upvotes downvotes _followingId _followersId reputation' + '_id name username avatar bio contributorBadge upvotes downvotes _followingId _followersId reputation' ) .lean(); if (!user) return null; - const userId = user._id.toString(); - - return { - ...user, - _id: userId, - // reputation is a plain nested object on the model, not a Mongoose - // subdocument, so it never gets its own _id — but UserReputationType - // requires one. Reuse the parent user's id since it's 1:1 embedded. - reputation: user.reputation - ? { ...user.reputation, _id: userId } - : undefined, - } as unknown as Common.User; + return asPublicUserDoc(user); }, searchUser: async ( _parent: unknown, @@ -50,15 +78,237 @@ export const userResolver = { accountStatus: 'active', }) .select( - '_id name username avatar contributorBadge upvotes downvotes _followingId _followersId reputation' + '_id name username avatar bio contributorBadge upvotes downvotes _followingId _followersId reputation' ) .limit(10) .lean(); - return users.map((user) => ({ - ...user, - _id: user._id.toString(), - })) as unknown as Common.User[]; + return users.map((user) => asPublicUserDoc(user)); + }, + }, + Mutation: { + updateUser: async ( + _parent: unknown, + args: { user: UpdateUserInput }, + context: GraphQLContext + ): Promise => { + if (!context.user?._id) { + throw new GraphQLError('Authentication required', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + + const input = args.user; + if (!input?._id) { + throw new GraphQLError('User id is required', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const actorId = context.user._id.toString(); + const targetId = input._id.toString(); + const isOwnProfile = actorId === targetId; + const isAdmin = context.user.admin === true; + + if (!isOwnProfile && !isAdmin) { + throw new GraphQLError('Not authorized to update this user', { + extensions: { code: 'FORBIDDEN' }, + }); + } + + // Admins updating another user may only toggle contributorBadge. + if (!isOwnProfile && isAdmin) { + if (input.contributorBadge === undefined || input.contributorBadge === null) { + throw new GraphQLError('Admins may only update contributorBadge for other users', { + extensions: { code: 'FORBIDDEN' }, + }); + } + + const adminUpdated = await User.findByIdAndUpdate( + targetId, + { $set: { contributorBadge: Boolean(input.contributorBadge) } }, + { new: true } + ).lean(); + + if (!adminUpdated) { + throw new GraphQLError('User not found', { + extensions: { code: 'NOT_FOUND' }, + }); + } + + return asPublicUserDoc(adminUpdated); + } + + const updates: Record = {}; + + if (input.name !== undefined && input.name !== null) { + const name = input.name.trim(); + if (!name) { + throw new GraphQLError('Name is required', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + if (name.length > 50) { + throw new GraphQLError('Name must be under 50 characters', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + updates.name = name; + } + + if (input.username !== undefined && input.username !== null) { + const username = input.username.trim(); + if (username.length < 4 || username.length > 50) { + throw new GraphQLError('Username must be between 4 and 50 characters', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const existingUsername = await User.findOne({ + _id: { $ne: targetId }, + username, + }) + .select('_id') + .lean(); + if (existingUsername) { + throw new GraphQLError('Username already exists!', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + updates.username = username; + } + + if (input.email !== undefined && input.email !== null) { + const email = input.email.trim().toLowerCase(); + if (!email) { + throw new GraphQLError('Email is required', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const existingEmail = await User.findOne({ + _id: { $ne: targetId }, + email, + }) + .select('_id') + .lean(); + if (existingEmail) { + throw new GraphQLError('Email address already exists!', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + updates.email = email; + } + + if (input.password) { + const salt = await bcrypt.genSalt(10); + updates.password = await bcrypt.hash(input.password, salt); + } + + if (input.avatar !== undefined && input.avatar !== null) { + updates.avatar = input.avatar; + } + + if (input.bio !== undefined) { + try { + updates.bio = normalizeBio(input.bio); + } catch (err) { + throw new GraphQLError(err instanceof Error ? err.message : 'Invalid About text', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + } + + if (input.themePreference !== undefined && input.themePreference !== null) { + const theme = input.themePreference.trim(); + if (theme !== 'light' && theme !== 'dark') { + throw new GraphQLError('themePreference must be light or dark', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + updates.themePreference = theme; + } + + if (input.contributorBadge !== undefined && input.contributorBadge !== null) { + if (!isAdmin) { + throw new GraphQLError('Only admins can update contributorBadge', { + extensions: { code: 'FORBIDDEN' }, + }); + } + updates.contributorBadge = Boolean(input.contributorBadge); + } + + if (Object.keys(updates).length === 0) { + const current = await User.findById(targetId).lean(); + if (!current) { + throw new GraphQLError('User not found', { + extensions: { code: 'NOT_FOUND' }, + }); + } + return asPublicUserDoc(current); + } + + const updated = await User.findByIdAndUpdate(targetId, { $set: updates }, { new: true }).lean(); + + if (!updated) { + throw new GraphQLError('User not found', { + extensions: { code: 'NOT_FOUND' }, + }); + } + + return asPublicUserDoc(updated); + }, + + updateUserAvatar: async ( + _parent: unknown, + args: { user_id: string; avatarQualities?: Record | null }, + context: GraphQLContext + ): Promise => { + if (!context.user?._id) { + throw new GraphQLError('Authentication required', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + + const targetId = args.user_id?.toString(); + if (!targetId) { + throw new GraphQLError('User id is required', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const actorId = context.user._id.toString(); + const isAdmin = context.user.admin === true; + if (actorId !== targetId && !isAdmin) { + throw new GraphQLError('Not authorized to update this avatar', { + extensions: { code: 'FORBIDDEN' }, + }); + } + + if ( + args.avatarQualities === undefined || + args.avatarQualities === null || + typeof args.avatarQualities !== 'object' || + Array.isArray(args.avatarQualities) + ) { + throw new GraphQLError('avatarQualities must be an object', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const updated = await User.findByIdAndUpdate( + targetId, + { $set: { avatar: args.avatarQualities } }, + { new: true } + ).lean(); + + if (!updated) { + throw new GraphQLError('User not found', { + extensions: { code: 'NOT_FOUND' }, + }); + } + + return asPublicUserDoc(updated); }, }, }; diff --git a/quotevote-backend/app/data/types/User.ts b/quotevote-backend/app/data/types/User.ts index d3a1d013..e3c665bb 100644 --- a/quotevote-backend/app/data/types/User.ts +++ b/quotevote-backend/app/data/types/User.ts @@ -46,6 +46,7 @@ export const UserType: GraphQLObjectType = new Grap tokens: { type: GraphQLInt }, _wallet: { type: GraphQLString, resolve: (u) => u._wallet }, avatar: { type: JSONScalar }, + bio: { type: GraphQLString }, _followersId: { type: new GraphQLList(GraphQLString), resolve: (u) => u._followersId ?? [], @@ -94,7 +95,15 @@ export const UserType: GraphQLObjectType = new Grap }, presence: { type: PresenceType, - resolve: (user) => Presence.findOne({ userId: user._id }).lean(), + resolve: async (user) => { + const doc = await Presence.findOne({ userId: user._id }).lean(); + if (!doc) return null; + return { + ...doc, + _id: doc._id.toString(), + userId: doc.userId.toString(), + }; + }, }, rosters: { type: new GraphQLList(RosterType), diff --git a/quotevote-backend/app/data/types/UserReputation.ts b/quotevote-backend/app/data/types/UserReputation.ts index 2b0c02bf..9aec4f3c 100644 --- a/quotevote-backend/app/data/types/UserReputation.ts +++ b/quotevote-backend/app/data/types/UserReputation.ts @@ -12,6 +12,7 @@ import { UserType } from './User'; import { ReportReasonEnum, ReportStatusEnum, ReportSeverityEnum } from './enums'; import User from '../models/User'; +import { toIsoDateString } from '../utils/serializeDate'; export const ReputationMetricsType: GraphQLObjectType = new GraphQLObjectType({ @@ -74,24 +75,22 @@ export const UserReputationType: GraphQLObjectType - rep.lastCalculated instanceof Date - ? rep.lastCalculated.toISOString() - : String(rep.lastCalculated), + // Always ISO-8601 strings for the client (never raw epoch numbers). + resolve: (rep) => toIsoDateString(rep.lastCalculated), }, createdAt: { type: new GraphQLNonNull(GraphQLString), - resolve: (rep) => { - const v = (rep as Common.Reputation & { createdAt?: Date | string }).createdAt; - return v instanceof Date ? v.toISOString() : (v ?? ''); - }, + resolve: (rep) => + toIsoDateString( + (rep as Common.Reputation & { createdAt?: Date | string }).createdAt + ), }, updatedAt: { type: new GraphQLNonNull(GraphQLString), - resolve: (rep) => { - const v = (rep as Common.Reputation & { updatedAt?: Date | string }).updatedAt; - return v instanceof Date ? v.toISOString() : (v ?? ''); - }, + resolve: (rep) => + toIsoDateString( + (rep as Common.Reputation & { updatedAt?: Date | string }).updatedAt + ), }, }), }); diff --git a/quotevote-backend/app/data/utils/authentication.ts b/quotevote-backend/app/data/utils/authentication.ts index fc3415c1..8df2986d 100644 --- a/quotevote-backend/app/data/utils/authentication.ts +++ b/quotevote-backend/app/data/utils/authentication.ts @@ -206,6 +206,8 @@ export const addCreatorToUser = async ( email: user.email, admin: user.admin, accountStatus: user.accountStatus, + avatar: user.avatar ?? null, + bio: user.bio ?? '', }, }); }; diff --git a/quotevote-backend/app/data/utils/bioValidation.ts b/quotevote-backend/app/data/utils/bioValidation.ts new file mode 100644 index 00000000..3aa72796 --- /dev/null +++ b/quotevote-backend/app/data/utils/bioValidation.ts @@ -0,0 +1,32 @@ +/** + * Shared validation for user About / bio text. + * Plain text only; max length matches frontend PROFILE_BIO_MAX_LENGTH. + */ + +export const BIO_MAX_LENGTH = 500; + +/** Detects HTML / markup that should not be stored as plain-text bio. */ +const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i; + +/** + * Normalize and validate bio input for updateUser. + * Returns trimmed plain text, or empty string when cleared. + * Throws Error with a user-facing message on invalid input. + */ +export function normalizeBio(raw: string | null | undefined): string { + if (raw == null) { + return ''; + } + + const trimmed = raw.trim(); + + if (trimmed.length > BIO_MAX_LENGTH) { + throw new Error(`About must be ${BIO_MAX_LENGTH} characters or fewer`); + } + + if (HTML_TAG_PATTERN.test(trimmed)) { + throw new Error('About must be plain text without HTML'); + } + + return trimmed; +} diff --git a/quotevote-backend/app/data/utils/serializeDate.ts b/quotevote-backend/app/data/utils/serializeDate.ts new file mode 100644 index 00000000..091a2b23 --- /dev/null +++ b/quotevote-backend/app/data/utils/serializeDate.ts @@ -0,0 +1,27 @@ +/** + * Normalize Date / epoch / date-string values to ISO-8601 for GraphQL String fields. + * Always returns a string (empty when invalid) so clients can treat dates as strings. + */ +export function toIsoDateString(value: unknown): string { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? '' : value.toISOString(); + } + + if (typeof value === 'number' && Number.isFinite(value)) { + // Seconds vs milliseconds (Unix seconds are < 1e12 until year ~33658). + const ms = value > 0 && value < 1e12 ? value * 1000 : value; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); + } + + if (typeof value === 'string' && value.trim()) { + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) { + return toIsoDateString(Number(trimmed)); + } + const date = new Date(trimmed); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); + } + + return ''; +} diff --git a/quotevote-backend/app/server.ts b/quotevote-backend/app/server.ts index 144df1b3..24b8e626 100644 --- a/quotevote-backend/app/server.ts +++ b/quotevote-backend/app/server.ts @@ -130,6 +130,8 @@ async function startServer() { solidPushPortableState(input: PortableStateInput!): Boolean solidAppendActivityEvent(input: ActivityEventInput!): Boolean heartbeat: HeartbeatResponse + updateUser(user: UserInput!): User + updateUserAvatar(user_id: String!, avatarQualities: JSON): User } type SolidConnectionStatus { diff --git a/quotevote-backend/app/types/common.ts b/quotevote-backend/app/types/common.ts index f2b59a50..734b80fb 100644 --- a/quotevote-backend/app/types/common.ts +++ b/quotevote-backend/app/types/common.ts @@ -67,7 +67,8 @@ export interface Reputation { conductScore: number; activityScore: number; metrics: ReputationMetrics; - lastCalculated: Date | string; + /** Stored as Date in Mongo; GraphQL serializes to ISO-8601 string. */ + lastCalculated: Date | string | number; } export interface User { diff --git a/quotevote-backend/app/types/graphql.ts b/quotevote-backend/app/types/graphql.ts index 5e5497f6..f27bace0 100644 --- a/quotevote-backend/app/types/graphql.ts +++ b/quotevote-backend/app/types/graphql.ts @@ -169,6 +169,28 @@ export interface QueryResolvers { export interface MutationResolvers { // User mutations followUser: ResolverFn; + updateUser: ResolverFn< + Common.User, + unknown, + { + user: { + _id: string; + name?: string | null; + username?: string | null; + email?: string | null; + password?: string | null; + avatar?: string | null; + bio?: string | null; + contributorBadge?: boolean | null; + themePreference?: string | null; + }; + } + >; + updateUserAvatar: ResolverFn< + Common.User, + unknown, + { user_id: string; avatarQualities?: Record | null } + >; updateUserPassword: ResolverFn< boolean, unknown, @@ -232,7 +254,11 @@ export interface MutationResolvers { // Presence mutations heartbeat: ResolverFn; - updatePresence: ResolverFn; + updatePresence: ResolverFn< + Common.Presence, + unknown, + { presence: { status: string; statusMessage?: string | null } } + >; // Typing mutations updateTyping: ResolverFn; @@ -411,7 +437,9 @@ export interface MutationResult { export interface HeartbeatResult { success: boolean; - timestamp: number; + timestamp: string | number; + status?: string; + statusMessage?: string; } export interface TypingResult { diff --git a/quotevote-frontend/src/__tests__/app/dashboard/settings/page.test.tsx b/quotevote-frontend/src/__tests__/app/dashboard/settings/page.test.tsx index ee5255b8..dd70bdb3 100644 --- a/quotevote-frontend/src/__tests__/app/dashboard/settings/page.test.tsx +++ b/quotevote-frontend/src/__tests__/app/dashboard/settings/page.test.tsx @@ -3,6 +3,7 @@ import { resetStore } from '@/__tests__/utils/test-utils' import { installMemoryStorage, restoreStorage } from '@/__tests__/utils/memoryStorage' import { useAppStore } from '@/store/useAppStore' import { UPDATE_USER } from '@/graphql/mutations' +import { GET_USER } from '@/graphql/queries' // Mock next/navigation const mockPush = jest.fn() @@ -14,10 +15,12 @@ jest.mock('next/navigation', () => ({ // Mock ThemeContext const mockToggleTheme = jest.fn().mockReturnValue('light') +const mockSetTheme = jest.fn() const mockToggleNeoBrutalism = jest.fn().mockReturnValue(false) jest.mock('@/context/ThemeContext', () => ({ useTheme: () => ({ themeMode: 'light', + setTheme: mockSetTheme, toggleTheme: mockToggleTheme, isDarkMode: false, neoBrutalism: false, @@ -38,11 +41,36 @@ const mockUser = { username: 'testuser', name: 'Test User', email: 'test@example.com', + bio: 'Existing about text', avatar: 'https://example.com/avatar.png', admin: false, accountStatus: 'active', } +const getUserMock = { + request: { + query: GET_USER, + variables: { username: 'testuser' }, + }, + result: { + data: { + user: { + _id: 'user-1', + name: 'Test User', + username: 'testuser', + bio: 'Existing about text', + upvotes: 0, + downvotes: 0, + _followingId: [], + _followersId: [], + avatar: 'https://example.com/avatar.png', + contributorBadge: false, + reputation: null, + }, + }, + }, +} + const updateUserMock = { request: { query: UPDATE_USER, @@ -52,6 +80,8 @@ const updateUserMock = { name: 'Updated Name', username: 'testuser', email: 'test@example.com', + bio: 'Existing about text', + themePreference: 'light', }, }, }, @@ -63,6 +93,7 @@ const updateUserMock = { username: 'testuser', email: 'test@example.com', name: 'Updated Name', + bio: 'Existing about text', avatar: 'https://example.com/avatar.png', admin: false, accountStatus: 'active', @@ -81,45 +112,46 @@ describe('Settings Page', () => { }) it('renders settings page heading', () => { - render() + render(, { mocks: [getUserMock] }) expect(screen.getByRole('heading', { name: /settings/i })).toBeInTheDocument() }) it('renders unified form with all fields', () => { - render() + render(, { mocks: [getUserMock] }) expect(screen.getByLabelText('Display Name')).toBeInTheDocument() expect(screen.getByLabelText('Username')).toBeInTheDocument() expect(screen.getByLabelText('Email')).toBeInTheDocument() - // Password field is currently hidden + expect(screen.getByLabelText('About')).toBeInTheDocument() }) it('renders profile form with user data by default', () => { - render() + render(, { mocks: [getUserMock] }) expect(screen.getByDisplayValue('Test User')).toBeInTheDocument() expect(screen.getByDisplayValue('testuser')).toBeInTheDocument() expect(screen.getByDisplayValue('test@example.com')).toBeInTheDocument() + expect(screen.getByDisplayValue('Existing about text')).toBeInTheDocument() }) it('renders dark mode toggle', () => { - render() + render(, { mocks: [getUserMock] }) expect(screen.getByText('Dark Mode')).toBeInTheDocument() expect(screen.getByRole('switch', { name: /toggle dark mode/i })).toBeInTheDocument() }) it.skip('renders optional password field', () => { // Password field is currently hidden/commented out - render() + render(, { mocks: [getUserMock] }) expect(screen.getByPlaceholderText('Leave blank to keep current password')).toBeInTheDocument() }) it('shows save button as disabled when form is pristine', () => { - render() + render(, { mocks: [getUserMock] }) const saveButton = screen.getByRole('button', { name: /save changes/i }) expect(saveButton).toBeDisabled() }) it('enables save button when form is dirty', async () => { - render(, { mocks: [updateUserMock] }) + render(, { mocks: [getUserMock, updateUserMock] }) const nameInput = screen.getByDisplayValue('Test User') fireEvent.change(nameInput, { target: { value: 'Updated Name' } }) await waitFor(() => { @@ -129,24 +161,24 @@ describe('Settings Page', () => { }) it('shows change avatar button', () => { - render() + render(, { mocks: [getUserMock] }) expect(screen.getByLabelText('Change avatar')).toBeInTheDocument() }) it('navigates to avatar page when avatar is clicked', () => { - render() + render(, { mocks: [getUserMock] }) fireEvent.click(screen.getByLabelText('Change avatar')) expect(mockPush).toHaveBeenCalledWith('/dashboard/profile/testuser/avatar') }) it('renders sign out button', () => { - render() + render(, { mocks: [getUserMock] }) expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument() }) it.skip('validates password requirements', async () => { // Password field is currently hidden/commented out - render() + render(, { mocks: [getUserMock] }) const pwInput = screen.getByPlaceholderText('Leave blank to keep current password') fireEvent.change(pwInput, { target: { value: 'short' } }) @@ -169,14 +201,14 @@ describe('Settings Page', () => { }) it('renders the profile background section with pattern options', () => { - render() + render(, { mocks: [getUserMock] }) expect(screen.getByText('Profile Background')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Zigzag' })).toBeInTheDocument() expect(screen.getByLabelText('Profile background preview')).toBeInTheDocument() }) it('persists the selected pattern', async () => { - render() + render(, { mocks: [getUserMock] }) fireEvent.click(screen.getByRole('button', { name: 'Zigzag' })) await waitFor(() => { @@ -188,13 +220,42 @@ describe('Settings Page', () => { ) }) + it('enables Save Changes when only the background pattern changes', async () => { + render(, { mocks: [getUserMock] }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled() + }) + + // Default pattern is zigzag — switch to a different one to dirty the form. + fireEvent.click(screen.getByRole('button', { name: 'Solid' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save changes/i })).not.toBeDisabled() + }) + }) + it('persists the selected color swatch', async () => { - render() + render(, { mocks: [getUserMock] }) fireEvent.click(screen.getByLabelText('Background color #3b82f6')) await waitFor(() => { expect(localStorage.getItem('profileBgColor')).toBe('#3b82f6') }) }) + + it('enables Save Changes when only the background color changes', async () => { + render(, { mocks: [getUserMock] }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled() + }) + + fireEvent.click(screen.getByLabelText('Background color #3b82f6')) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save changes/i })).not.toBeDisabled() + }) + }) }) }) diff --git a/quotevote-frontend/src/__tests__/components/Profile/ProfileAvatar.test.tsx b/quotevote-frontend/src/__tests__/components/Profile/ProfileAvatar.test.tsx index 7d2e0982..2ccc5ff3 100644 --- a/quotevote-frontend/src/__tests__/components/Profile/ProfileAvatar.test.tsx +++ b/quotevote-frontend/src/__tests__/components/Profile/ProfileAvatar.test.tsx @@ -64,7 +64,6 @@ describe('ProfileAvatar', () => { user: { loading: false, loginError: null, - // @ts-expect-error - avatar as object in test data: { avatar: qualities }, }, }); diff --git a/quotevote-frontend/src/__tests__/components/Profile/ProfileController.test.tsx b/quotevote-frontend/src/__tests__/components/Profile/ProfileController.test.tsx index 3903074c..4a783b19 100644 --- a/quotevote-frontend/src/__tests__/components/Profile/ProfileController.test.tsx +++ b/quotevote-frontend/src/__tests__/components/Profile/ProfileController.test.tsx @@ -25,8 +25,10 @@ jest.mock('../../../components/Profile/ProfileView', () => ({ })); // Mock Next.js router +const mockReplace = jest.fn(); jest.mock('next/navigation', () => ({ useParams: () => ({ username: 'testuser' }), + useRouter: () => ({ replace: mockReplace, push: jest.fn() }), })); const mockUserData = { @@ -71,6 +73,7 @@ const mockUserData = { describe('ProfileController', () => { beforeEach(() => { + mockReplace.mockClear(); useAppStore.setState({ user: { loading: false, diff --git a/quotevote-frontend/src/__tests__/components/Profile/ProfileHeader.test.tsx b/quotevote-frontend/src/__tests__/components/Profile/ProfileHeader.test.tsx index e7bf37d5..f2176201 100644 --- a/quotevote-frontend/src/__tests__/components/Profile/ProfileHeader.test.tsx +++ b/quotevote-frontend/src/__tests__/components/Profile/ProfileHeader.test.tsx @@ -150,10 +150,29 @@ describe('ProfileHeader Component', () => { }); // Component may be caught by ErrorBoundary if queries fail, so check for either username or error UI await waitFor(() => { - const username = screen.queryByText('testuser'); + const username = screen.queryByText('@testuser'); + const displayName = screen.queryByText('Test User'); const errorUI = screen.queryByText(/Something went wrong/i); - expect(username || errorUI).toBeTruthy(); + expect(username || displayName || errorUI).toBeTruthy(); + }, { timeout: 5000 }); + }); + + it('renders display name as the profile heading', async () => { + await act(async () => { + render( + + + + ); + }); + await waitFor(() => { + const heading = screen.queryByRole('heading', { name: 'Test User' }); + const errorUI = screen.queryByText(/Something went wrong/i); + expect(heading || errorUI).toBeTruthy(); }, { timeout: 5000 }); + if (!screen.queryByText(/Something went wrong/i)) { + expect(screen.getByText('@testuser')).toBeInTheDocument(); + } }); it('renders avatar with correct props', async () => { @@ -267,7 +286,7 @@ describe('ProfileHeader Component', () => { }, { timeout: 5000 }); }); - it('navigates to avatar page when clicking Edit Profile', async () => { + it('navigates to settings page when clicking Edit Profile', async () => { const ownProfile: ProfileUser = { ...mockProfileUser, _id: 'currentuser', @@ -292,7 +311,7 @@ describe('ProfileHeader Component', () => { await act(async () => { fireEvent.click(button); }); - expect(mockPush).toHaveBeenCalledWith('/dashboard/profile/testuser/avatar'); + expect(mockPush).toHaveBeenCalledWith('/dashboard/settings'); } else { // If ErrorBoundary caught an error, skip the navigation test expect(screen.queryByText(/Something went wrong/i)).toBeTruthy(); diff --git a/quotevote-frontend/src/__tests__/components/Profile/ProfileView.test.tsx b/quotevote-frontend/src/__tests__/components/Profile/ProfileView.test.tsx index 24f00dfc..349e2e18 100644 --- a/quotevote-frontend/src/__tests__/components/Profile/ProfileView.test.tsx +++ b/quotevote-frontend/src/__tests__/components/Profile/ProfileView.test.tsx @@ -91,10 +91,10 @@ describe('ProfileView', () => { expect(screen.getByText('Return to homepage.')).toBeInTheDocument(); }); - it('has link to search page', () => { + it('has link to home page', () => { render(); const link = screen.getByText('Return to homepage.'); - expect(link.closest('a')).toHaveAttribute('href', '/search'); + expect(link.closest('a')).toHaveAttribute('href', '/'); }); }); @@ -142,6 +142,24 @@ describe('ProfileView', () => { expect(screen.getByTestId('reputation-display')).toBeInTheDocument(); }); expect(screen.getByText('Reputation')).toBeInTheDocument(); + expect(screen.getByText('No about text yet')).toBeInTheDocument(); + }); + + it('shows bio text on About tab when present', async () => { + const user = userEvent.setup(); + const userWithBio: ProfileUser = { + ...mockProfileUser, + bio: 'I care about thoughtful dialogue.', + }; + await act(async () => { + render(); + }); + const aboutTab = screen.getByRole('tab', { name: 'About' }); + await user.click(aboutTab); + await waitFor(() => { + expect(screen.getByText('I care about thoughtful dialogue.')).toBeInTheDocument(); + }); + expect(screen.getByRole('heading', { name: 'About', level: 3 })).toBeInTheDocument(); }); it('shows voted activity list when Voted tab is clicked', async () => { @@ -156,7 +174,7 @@ describe('ProfileView', () => { }); }); - it('does not render reputation display when reputation is missing and About tab clicked', async () => { + it('shows empty about state when reputation is missing and About tab clicked', async () => { const user = userEvent.setup(); const userWithoutReputation: ProfileUser = { ...mockProfileUser, @@ -169,7 +187,7 @@ describe('ProfileView', () => { await user.click(aboutTab); await waitFor(() => { expect(screen.queryByTestId('reputation-display')).not.toBeInTheDocument(); - expect(screen.getByText('No additional information available')).toBeInTheDocument(); + expect(screen.getByText('No about text yet')).toBeInTheDocument(); }); }); diff --git a/quotevote-frontend/src/__tests__/components/Profile/ReputationDisplay.test.tsx b/quotevote-frontend/src/__tests__/components/Profile/ReputationDisplay.test.tsx index 63dbc518..52400acf 100644 --- a/quotevote-frontend/src/__tests__/components/Profile/ReputationDisplay.test.tsx +++ b/quotevote-frontend/src/__tests__/components/Profile/ReputationDisplay.test.tsx @@ -60,6 +60,27 @@ describe('ReputationDisplay', () => { it('displays last calculated date', () => { render(); expect(screen.getByText(/Last updated:/)).toBeInTheDocument(); + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + }); + + it('shows Not available when lastCalculated is invalid', () => { + const badReputation: Reputation = { + ...mockReputation, + lastCalculated: 'not-a-date', + }; + render(); + expect(screen.getByText(/Last updated: Not available/)).toBeInTheDocument(); + }); + + it('parses digit-only epoch strings defensively for lastCalculated', () => { + // GraphQL returns ISO strings; this covers older/non-GraphQL payloads. + const stamped: Reputation = { + ...mockReputation, + lastCalculated: String(Date.UTC(2024, 0, 15)), + }; + render(); + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + expect(screen.getByText(/Last updated:/)).toBeInTheDocument(); }); it('calls onRefresh when refresh button is clicked', () => { diff --git a/quotevote-frontend/src/__tests__/components/settings/SettingsContent.test.tsx b/quotevote-frontend/src/__tests__/components/settings/SettingsContent.test.tsx index 52cb3c0b..0452b032 100644 --- a/quotevote-frontend/src/__tests__/components/settings/SettingsContent.test.tsx +++ b/quotevote-frontend/src/__tests__/components/settings/SettingsContent.test.tsx @@ -41,6 +41,12 @@ jest.mock('@/components/ui/input', () => ({ ), })) +jest.mock('@/components/ui/textarea', () => ({ + Textarea: React.forwardRef>( + (props, ref) =>