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
97 changes: 97 additions & 0 deletions admin-wcc-app/__tests__/pages/admin/members/create.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CreateMemberPage from '@/pages/admin/members/create';
import * as api from '@/lib/api';
import * as AuthProvider from '@/components/AuthProvider';
import mockRouter from 'next-router-mock';

jest.mock('next/router', () => jest.requireActual('next-router-mock'));
jest.mock('@/lib/api');
jest.mock('@/components/AuthProvider');
jest.mock('@/lib/auth', () => ({
getStoredToken: jest.fn(() => 'mock-token'),
}));

global.scrollTo = jest.fn();

const mockApiFetch = api.apiFetch as jest.MockedFunction<typeof api.apiFetch>;
const mockUseAuth = AuthProvider.useAuth as jest.Mock;

describe('CreateMemberPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRouter.setCurrentUrl('/admin/members/create');

mockUseAuth.mockReturnValue({
token: 'mock-token',
roles: ['ADMIN'],
member: null,
logout: jest.fn(),
});
});

const fillRequiredFields = async (user: ReturnType<typeof userEvent.setup>) => {
await user.type(screen.getByLabelText(/full name/i), ' Jane Doe ');
await user.type(screen.getByLabelText(/email/i), 'jane@example.com');
await user.type(screen.getByLabelText(/position/i), 'Developer');
await user.type(screen.getByLabelText(/slack display name/i), ' jane doe ');

const countryInput = screen.getByLabelText(/country/i);
await user.click(countryInput);
await user.type(countryInput, 'United');

const countryOption = await screen.findByRole('option', {
name: /united states \(us\)/i,
});
await user.click(countryOption);

const memberTypesInput = screen.getByLabelText(/member types/i);
await user.click(memberTypesInput);

const memberOption = await screen.findByRole('option', {
name: /^member$/i,
});
await user.click(memberOption);
};

it('trims full name and Slack display name before submitting member data', async () => {
mockApiFetch.mockResolvedValueOnce({});

const user = userEvent.setup();

render(<CreateMemberPage />);
await fillRequiredFields(user);

await user.click(screen.getByRole('button', { name: /create member/i }));

await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalled();
});

const payload = mockApiFetch.mock.calls[0][1]?.body;

expect(payload).toMatchObject({
fullName: 'Jane Doe',
slackDisplayName: 'jane doe',
});
});

it.each([
['full name', /full name/i, 'Full name is required'],
['Slack display name', /slack display name/i, 'Slack display name is required'],
])('rejects a whitespace-only %s', async (_field, label, message) => {
const user = userEvent.setup();

render(<CreateMemberPage />);

await fillRequiredFields(user);
const input = screen.getByLabelText(label);
await user.clear(input);
await user.type(input, ' ');
await user.click(screen.getByRole('button', { name: /create member/i }));

expect(await screen.findByText(message)).toBeInTheDocument();
expect(mockApiFetch).not.toHaveBeenCalled();
});
});
10 changes: 8 additions & 2 deletions admin-wcc-app/pages/admin/members/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,15 @@ export default function CreateMemberPage() {
const validateForm = () => {
const errors: Record<string, string> = {};

if (!formData.fullName) errors.fullName = 'Full name is required';
if (!formData.fullName.trim()) {
errors.fullName = 'Full name is required';
}
if (!formData.position) errors.position = 'Position is required';
if (!formData.email) errors.email = 'Email is required';
else if (!validateEmail(formData.email)) errors.email = 'Invalid email format';
if (!formData.slackDisplayName) errors.slackDisplayName = 'Slack display name is required';
if (!formData.slackDisplayName.trim()) {
errors.slackDisplayName = 'Slack display name is required';
}
if (!formData.country?.countryCode) errors.country = 'Country is required';
if (!formData.memberTypes || formData.memberTypes.length === 0) {
errors.memberTypes = 'At least one member type is required';
Expand Down Expand Up @@ -166,6 +170,8 @@ export default function CreateMemberPage() {

const submitData = {
...restFormData,
fullName: restFormData.fullName.trim(),
slackDisplayName: restFormData.slackDisplayName.trim(),
country: {
countryCode: country?.countryCode,
countryName: country?.countryName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public Long addMember(final Member member) {
final int defaultStatusPending = 1;
jdbc.update(
INSERT,
member.getFullName(),
StringUtils.trim(member.getFullName()),
member.getSlackDisplayName(),
member.getPosition(),
member.getCompanyName(),
Expand Down Expand Up @@ -153,7 +153,7 @@ private Long findMemberIdByEmail(final String email) {
public void updateMember(final Member member, final Long memberId) {
jdbc.update(
UPDATE_SQL,
member.getFullName(),
StringUtils.trim(member.getFullName()),
member.getSlackDisplayName(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of adding this "StringUtils.trim()" this should be handled at the time of deserialization. And then for the fields we do not need the Trim can have custom annotation say - NoTrim can be used if we do not want to strip the leading/trailing spaces.

Suggested change
member.getSlackDisplayName(),
1. An opt-out annotation
package com.wcc.platform.deserializers;
/** Marks a String property that must be deserialized as-is, without trimming (e.g. passwords). */
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoTrim {}
2. A trimming String deserializer
package com.wcc.platform.deserializers;
/**
* Strips leading and trailing whitespace from every JSON string value, unless the target
* property is annotated with {@link NoTrim}.
*/
public class TrimmingStringDeserializer extends StdScalarDeserializer<String>
implements ContextualDeserializer {
public TrimmingStringDeserializer() {
super(String.class);
}
@Override
public String deserialize(final JsonParser parser, final DeserializationContext context)
throws IOException {
final String value = StringDeserializer.instance.deserialize(parser, context);
return value == null ? null : value.strip();
}
@Override
public JsonDeserializer<?> createContextual(
final DeserializationContext context, final BeanProperty property) {
if (property != null && property.getAnnotation(NoTrim.class) != null) {
return StringDeserializer.instance;
}
return this;
}
}
It uses strip() rather than trim() because strip() also removes Unicode whitespace, such as a non-breaking space pasted from a document.
3. Register it once in ObjectMapperConfig
new SimpleModule()
.addDeserializer(String.class, new TrimmingStringDeserializer())
// ...existing serializers/deserializers
4. Opt out where needed
public record LoginRequest(@NotBlank String email, @NoTrim @NotBlank String password) {}
After this, fullName, email, city and every other string, including items inside List<String>, arrive already trimmed. The mappers don't need to do anything, and @NotBlank checks the trimmed value.
Eg -
- Password fields need @NoTrim. They're in AuthController (login), PasswordResetService request types, and SeedUser. Each one missed would silently change what the user typed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hi @Sowmiya07 and @goelsonali

@Sowmiya07, you mentioned collecting the other fields that need trimming. @goelsonali, you suggested a global trimming deserializer.

That approach would trim JSON string values across the API, with explicit opt-outs for passwords and other whitespace-sensitive fields, so it broadens the scope beyond member names.

Could you confirm whether you’d prefer to keep this PR focused on member fields and introduce the global deserializer in a follow-up, or include it here?

In the meantime, I’ve addressed Slack display name trimming and validation in the admin form.

member.getPosition(),
member.getCompanyName(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
Expand Down Expand Up @@ -87,7 +88,7 @@ void testMapRowToMember() throws SQLException {
@Test
void testAddMember() {
Member member = mock(Member.class);
when(member.getFullName()).thenReturn("Jane Doe");
when(member.getFullName()).thenReturn(" Jane Doe ");
when(member.getSlackDisplayName()).thenReturn("jane");
when(member.getPosition()).thenReturn("Manager");
when(member.getCompanyName()).thenReturn("WCC");
Expand All @@ -111,14 +112,30 @@ void testAddMember() {
Long memberId = memberMapper.addMember(member);

assertEquals(10L, memberId);

verify(jdbc)
.update(
anyString(),
eq("Jane Doe"),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any());

verify(memberTypeRepo, never()).addMemberType(anyLong(), anyInt());
verify(socialNetworkRepo, never()).addSocialNetwork(anyLong(), any());
}

@Test
void testUpdateMember() {
Member member = mock(Member.class);
when(member.getFullName()).thenReturn("Jane Doe");
when(member.getFullName()).thenReturn(" Jane Doe ");
when(member.getSlackDisplayName()).thenReturn("jane");
when(member.getPosition()).thenReturn("Manager");
when(member.getCompanyName()).thenReturn("WCC");
Expand All @@ -139,7 +156,7 @@ void testUpdateMember() {
verify(jdbc)
.update(
anyString(),
any(),
eq("Jane Doe"),
any(),
any(),
any(),
Expand Down
Loading