Skip to content

Conversation

clairep94
Copy link
Collaborator

@clairep94 clairep94 commented Oct 10, 2025

Migration of the userController to ts

Changes:

  • Add unit tests to all userController methods, which can act as examples of how to test for other contributors to do migration work
  • Organise user routes into "subdomains" (related topics of routes)
  • Move userController methods into barrel structure based on these "subdomains" -- NO LOGIC CHANGES
  • Explicitly declare userController methods as Express RequestHandler & extract the types for each route --> added to relevant /server/types file:
    • params
    • responseBody
    • requestBody
    • query
    • These are then intended to be legible by the frontend to create tighter contracts (eg. user preferences redux)
  • Extend the User type in the Express namespace so that each Request.user is our custom User type
  • Added jsdocs to each type & controller method
  • Added simple mock files for some mailer related modules that kept being used in user controllers

Notes:
I have some suggestions for logic improvements on the methods, but I will make those on a PR into this branch that I will link below when completed so that it's easier to view the diff

I have verified that this pull request:

  • has no linting errors (npm run lint)
  • has no test errors (npm run test)
  • is from a uniquely-named feature branch and is up to date with the develop branch.
  • is descriptively named and links to an issue number, i.e. Fixes #123
  • meets the standards outlined in the accessibility guidelines

…eferences to /userPreferences & add response and request types
…teCookiePreferences & move to /userPreferences
…e to /authmanagement and add request and response types
Comment on lines 35 to 80
describe('createUser', () => {
it('should return 422 if email already exists', async () => {
User.findByEmailAndUsername = jest.fn().mockResolvedValue({
email: '[email protected]',
username: 'anyusername'
});

request.setBody({
username: 'testuser',
email: '[email protected]',
password: 'password'
});

await createUser(request, response, next);

expect(User.findByEmailAndUsername).toHaveBeenCalledWith(
'[email protected]',
'testuser'
);
expect(response.status).toHaveBeenCalledWith(422);
expect(response.send).toHaveBeenCalledWith({ error: 'Email is in use' });
});
it('should return 422 if username already exists', async () => {
User.findByEmailAndUsername = jest.fn().mockResolvedValue({
email: '[email protected]',
username: 'testuser'
});

request.setBody({
username: 'testuser',
email: '[email protected]',
password: 'password'
});

await createUser(request, response, next);

expect(User.findByEmailAndUsername).toHaveBeenCalledWith(
'[email protected]',
'testuser'
);
expect(response.status).toHaveBeenCalledWith(422);
expect(response.send).toHaveBeenCalledWith({
error: 'Username is in use'
});
});
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the only controller method where not all the logic was tested

I couldn't work out how to control what new User({..}) does in the test environment, but everything is otherwise 100% tested

* Description:
* - Create API key
*/
export const createApiKey: RequestHandler<
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought putting these here helped a lot with hints in the IDE when you hover the method, but let me know if it should go elsewhere, or if you prefer a different format

Screenshot 2025-10-10 at 09 05 28

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great!

res.status(404).json({ error: 'User not found' });
return;
}
user.username = req.body.username;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
user.username = req.body.username;
user.username = req.body.username ?? user.username;

Or error early if req.body doesn't have username or email

res.status(401).json({ error: 'Current password is invalid.' });
return;
}
user.password = req.body.newPassword!;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i had to add ! to req.body.newPassword to resolve the typeError where newpassword might not exist, without changing the code logic, but I would propose to move this block within if (req.body.newPassword)

/**
* - Method: `DELETE`
* - Endpoint: `/auth/github`
* - Authenticated: `false` -- TODO: update to true?
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is currently not a route that goes after the isAuthenticated middleware, should we put it behind isAuthenticated and remove the login checks?

On the UI I think I can only see if I'm logged in

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree!! that makes sense

/**
* - Method: `DELETE`
* - Endpoint: `/auth/google`
* - Authenticated: `false` -- TODO: update to true?
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

domain: `${protocol}://${req.headers.host}`,
link: `${protocol}://${req.headers.host}/verify?t=${token}`
},
to: req.user!.email
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added to resolve type error for user potentially not existing
safe to do so since this is after middleware

> = async (req, res) => {
try {
const token = await generateToken();
const user = await User.findById(req.user!.id).exec();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added to resolve type error for user potentially not existing
this isn't a route thats hidden past the middleware, but I think it's called after the user has signed up, so their new user is already attached to the request/passport right?

UpdatePreferencesRequestBody
> = async (req, res) => {
try {
const user = await User.findById(req.user!.id).exec();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added to resolve type error for user potentially not existing
safe to do so since this is after middleware

UpdateCookieConsentRequestBody
> = async (req, res) => {
try {
const user = await User.findById(req.user!.id).exec();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added to resolve type error for user potentially not existing
safe to do so since this is after middleware

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file was originally in server/utils but it's a middleware so I thought it was more appropriate here
added test before moving

"lib": ["ES2022"],
"types": ["node", "jest"]
"types": ["node", "jest", "express"],
"typeRoots": ["./types", "../node_modules/@types"]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated so that we can extend the express namespace user type to be our user type in each request

Copy link
Collaborator Author

@clairep94 clairep94 Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added so that we can extend the express namespace user type to be our user type in each request.user

@clairep94 clairep94 force-pushed the pr05/migrate_user_controller_oct branch from 899c10c to 1426142 Compare October 10, 2025 13:17
@clairep94 clairep94 changed the title Pr05/migrate User Controller pr05 Typescript Migration #14: Migrate User Controller Oct 10, 2025
… BDD, simplified after manual testingon client
Comment on lines 349 to 372
describe.skip('when given old username, old email, and matching current password and no new password', () => {
beforeEach(async () => {
requestBody = {
...minimumValidRequest,
currentPassword: OLD_PASSWORD
};
request.setBody(requestBody);
await updateSettings(request, response, next);
});

it('returns 401 with an "New password is required" message', () => {
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith({
error: 'New password is required.'
});
});

it('does not save the user with the new password', () => {
expect(saveUser).not.toHaveBeenCalled();
});
it('does not send a confirmation email to the user', () => {
expect(mailerService.send).not.toHaveBeenCalled();
});
});
Copy link
Collaborator Author

@clairep94 clairep94 Oct 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's client-side protection for this, but maybe we should guard against it just in case?

Currently it would save with the user.password: undefined

Comment on lines 307 to 347
describe.skip('when missing username', () => {
beforeEach(async () => {
request.setBody({ email: OLD_EMAIL });
await updateSettings(request, response, next);
});

it('returns 401 with an "Missing username" message', () => {
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith({
error: 'Username is required.'
});
});

it('does not save the user with the new password', () => {
expect(saveUser).not.toHaveBeenCalled();
});
it('does not send a confirmation email to the user', () => {
expect(mailerService.send).not.toHaveBeenCalled();
});
});

describe.skip('when missing email', () => {
beforeEach(async () => {
request.setBody({ username: OLD_USERNAME });
await updateSettings(request, response, next);
});

it('returns 401 with an "Missing email" message', () => {
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith({
error: 'Email is required.'
});
});

it('does not save the user with the new password', () => {
expect(saveUser).not.toHaveBeenCalled();
});
it('does not send a confirmation email to the user', () => {
expect(mailerService.send).not.toHaveBeenCalled();
});
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently doesn't return an error but maybe this would add safety, will propose on the logic-change PR

Comment on lines 273 to 304
describe.skip('when given new username, new email, and new password with valid current password', () => {
beforeEach(async () => {
requestBody = {
username: NEW_USERNAME,
email: NEW_EMAIL,
currentPassword: OLD_PASSWORD,
newPassword: NEW_PASSWORD
};
request.setBody(requestBody);
await updateSettings(request, response, next);
});
it('saves the user with the correct details once', () => {
expect(saveUser).toHaveBeenCalledWith(response, {
...startingUser,
username: NEW_USERNAME,
email: NEW_EMAIL,
verified: STATUSES.Sent,
verifiedToken: GENERATED_TOKEN,
verifiedTokenExpires: TOKEN_EXPIRY_TIME,
password: NEW_PASSWORD
});
expect(saveUser).toHaveBeenCalledTimes(1);
});
it('sends a confirmation email to the user', () => {
expect(mailerService.send).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Mock confirm your email'
})
);
});
});
});
Copy link
Collaborator Author

@clairep94 clairep94 Oct 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently doesn't pass, but given the UI seems to enable users to change both at the same time, I'll propose an update to the logic of the PR to do that

Screenshot 2025-10-10 at 15 33 03 Screenshot 2025-10-10 at 15 35 12

Comment on lines 352 to 375
describe.skip('when given old username, old email, and matching current password and no new password', () => {
beforeEach(async () => {
requestBody = {
...minimumValidRequest,
currentPassword: OLD_PASSWORD
};
request.setBody(requestBody);
await updateSettings(request, response, next);
});

it('returns 401 with an "New password is required" message', () => {
expect(response.status).toHaveBeenCalledWith(401);
expect(response.json).toHaveBeenCalledWith({
error: 'New password is required.'
});
});

it('does not save the user with the new password', () => {
expect(saveUser).not.toHaveBeenCalled();
});
it('does not send a confirmation email to the user', () => {
expect(mailerService.send).not.toHaveBeenCalled();
});
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's client-side protection for this, but maybe we should guard against it just in case?

Currently it would save with the user.password: undefined

let request;
let response;
describe('user.controller > api key', () => {
let request: any;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the type be MockRequest?

Copy link
Collaborator Author

@clairep94 clairep94 Oct 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah I wanted to do that but typing request: MockRequest and response: MockResponse results in a bunch of type errors because the MockRequest is missing a bunch of methods that actual express Request types have

Screenshot 2025-10-12 at 13 40 55

We could cast it like below per test instance, but it felt a bit verbose, what do you think?
Screenshot 2025-10-12 at 13 47 22

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think it's kind of nice that we're being explicit about what is happening, just updated to implement this!

jest.mock('../../../../utils/mail');

describe('user.controller > auth management > 3rd party auth', () => {
let request: any;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here (and for other files) is there any chance the type can be MockRequest ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

amazing, thank you so much for being thorough!

* Description:
* - Create API key
*/
export const createApiKey: RequestHandler<
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great!

* - Id: `UserController.resetPasswordInitiate`
*
* Description:
* - Send an Reset Email email to the registered email account
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small typo? "send a reset email..." ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated!

* @returns Sanitised user
*/
export function userResponse(
user: PublicUser & Record<string, any>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe <string, unknown>, if possible?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, i could be super wrong, but is there any chance the user parameter should be User or a similar type that has more fields? Otherwise it looks like we're not narrowing in on the fields

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to fix this!

/**
* - Method: `DELETE`
* - Endpoint: `/auth/github`
* - Authenticated: `false` -- TODO: update to true?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree!! that makes sense

khanniie
khanniie previously approved these changes Oct 11, 2025
Copy link
Member

@khanniie khanniie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you so much!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr05 Grant Projects pr05 Grant Projects

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants