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
107 changes: 107 additions & 0 deletions backend/src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,111 @@ router.delete('/', requireAuth, async (req, res) => {
}
});

/**
* PATCH /api/account
*
* Update the authenticated user's username and/or email address.
*
* Request body:
* { username?: string, email?: string }
*
* Response:
* 200 { ok: true, message: "Profile updated successfully", profile: { ... } }
* 400 Validation errors
* 409 Unique constraint violations (username taken or email in use)
* 500 Internal error
*/
router.patch('/', requireAuth, async (req, res) => {
const userId = req.user.id;
const { username, email } = req.body;

// Validate request
const errors = [];
if (username !== undefined) {
const trimmedUsername = String(username).trim();
if (trimmedUsername.length < 3 || trimmedUsername.length > 30) {
errors.push({ field: 'username', message: 'Username must be between 3 and 30 characters' });
}
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedUsername)) {
errors.push({ field: 'username', message: 'Username can only contain alphanumeric characters, underscores, and hyphens' });
}
}

if (email !== undefined) {
const trimmedEmail = String(email).trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
errors.push({ field: 'email', message: 'Please provide a valid email address' });
}
}

if (errors.length > 0) {
return res.status(400).json({ error: 'Validation failed', details: errors });
}

try {
let profileUpdate = {};

if (username !== undefined) {
profileUpdate.username = username.trim();
}

// 1. If updating email
if (email !== undefined) {
const targetEmail = email.trim();
const { error: authError } = await supabase.auth.admin.updateUserById(userId, {
email: targetEmail
});

if (authError) {
console.error('[account] auth email update failed:', authError.message);
if (authError.message.includes('already exists') || authError.message.includes('unique') || authError.status === 422) {
return res.status(409).json({ error: 'Email address is already in use by another account' });
}
return res.status(400).json({ error: authError.message });
}
}

// 2. If updating username
if (Object.keys(profileUpdate).length > 0) {
const { data, error: profileError } = await supabase
.from('profiles')
.update(profileUpdate)
.eq('id', userId)
.select()
.single();

if (profileError) {
console.error('[account] profile update failed:', profileError.message);
if (profileError.code === '23505' || profileError.message.includes('unique')) {
return res.status(409).json({ error: 'Username is already taken' });
}
return res.status(400).json({ error: profileError.message });
}
}

// 3. Fetch updated profile
const { data: updatedProfile, error: getError } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();

if (getError) throw getError;

return res.status(200).json({
ok: true,
message: 'Profile updated successfully',
profile: updatedProfile
});

} catch (error) {
console.error('[account] update unexpected error:', error);
return res.status(500).json({
error: 'Failed to update profile',
details: error.message
});
}
});

export default router;

76 changes: 75 additions & 1 deletion src/lib/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { verifyBatch, registerBatch, verifyBatchById } from "../api";
import { verifyBatch, registerBatch, verifyBatchById, updateProfile } from "../api";

// ---------------------------------------------------------------------------
// vi.mock() is hoisted to the top of the file by Vitest's transform, so any
Expand Down Expand Up @@ -397,3 +397,77 @@ describe("verifyBatchById", () => {
);
});
});

// ---------------------------------------------------------------------------
// updateProfile — Issue #143 tests
// ---------------------------------------------------------------------------
describe("updateProfile", () => {
let fetchSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
// Authenticated session by default
mockGetSession.mockResolvedValue({
data: { session: { access_token: "test-jwt-token" } },
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it("should make PATCH request to /api/account with correct body and headers", async () => {
const mockResponse = {
ok: true,
message: "Profile updated successfully",
profile: { username: "new_username" },
};

fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(mockResponse),
} as any);

const result = await updateProfile({ username: "new_username" });

expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/account"),
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: "Bearer test-jwt-token",
},
body: JSON.stringify({ username: "new_username" }),
},
);
expect(result).toEqual(mockResponse);
});

it("should throw error if session is not active", async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });

await expect(updateProfile({ username: "new_username" })).rejects.toThrow(
"No active session",
);
});

it("should throw server error when response not ok", async () => {
fetchSpy.mockResolvedValue({
ok: false,
status: 409,
json: vi.fn().mockResolvedValue({
error: "Username is already taken",
}),
} as any);

await expect(updateProfile({ username: "taken_username" })).rejects.toThrow(
"Username is already taken",
);
});
});

45 changes: 45 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,3 +848,48 @@ export const deleteAccount = async (): Promise<{ ok: boolean; message: string }>
throw new Error('deleteAccount failed: network error');
}
};

/**
* Updates the authenticated user's profile details (username/email) via the backend.
*/
export const updateProfile = async (
data: { username?: string; email?: string }
): Promise<{ ok: boolean; message: string; profile: any }> => {
const headers = await buildAuthHeaders();
headers["Content-Type"] = "application/json";

if (!headers["Authorization"]) {
throw new Error("No active session");
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let payload: any = null;

try {
const response = await fetch(`${API_BASE_URL}/api/account`, {
method: "PATCH",
headers,
body: JSON.stringify(data),
});

try {
payload = await response.json();
} catch {
// ignore
}

if (!response.ok) {
throw new Error(
payload?.error ||
payload?.message ||
`updateProfile failed: HTTP ${response.status}`
);
}

return payload;
} catch (err) {
if (err instanceof Error) throw err;
throw new Error("updateProfile failed: network error");
}
};

Loading
Loading