Skip to content
Merged
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
45 changes: 36 additions & 9 deletions client/src/__test__/context/AuthProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import AuthProvider from '../../context/AuthProvider';
import AuthContext, { AuthContextData } from '../../context/AuthContext';
import api from '../../api-service/api';
import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants';
import ApiConfig from '../../api-service/apiConfig';

// Mock the API service methods.
vi.mock('../../api-service/api');
Expand Down Expand Up @@ -119,17 +120,29 @@ describe('AuthProvider', () => {
});

it('should set loading to false and signed to true after successful initial auth check', async () => {
const fakeResponse = {
const fakeTokenResponse = {
token: 'refresh-token',
};
const fakeCurrentUser = {
userId: '789',
name: 'Refreshed User',
email: 'refreshed@example.com',
admin: false,
createdAt: new Date().toISOString(),
gravatarImageUrl: 'http://dummyimage.com'
gravatarImageUrl: 'http://dummyimage.com',
lang: 'en',
lastLogin: new Date().toISOString()
};

vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
if (url === ApiConfig.refreshTokenUrl) {
return fakeTokenResponse;
}
if (url === ApiConfig.currentUserUrl) {
return fakeCurrentUser;
}
return undefined;
});
localStorage.setItem(API_TOKEN, 'dummy');

const { getByTestId } = render(
Expand All @@ -142,6 +155,7 @@ describe('AuthProvider', () => {
expect(getByTestId('loading').textContent).toBe('false')
);
expect(getByTestId('signed').textContent).toBe('true');
expect(getByTestId('user').textContent).toBe('Refreshed User');
});

it('should sign in a user successfully', async () => {
Expand Down Expand Up @@ -251,17 +265,29 @@ describe('AuthProvider', () => {
});

it('should call fetchCurrentSession when checking current auth user', async () => {
const fakeResponse = {
const fakeTokenResponse = {
token: 'refresh-token',
};
const fakeCurrentUser = {
userId: '789',
name: 'Refreshed User',
email: 'refreshed@example.com',
admin: false,
createdAt: new Date().toISOString(),
gravatarImageUrl: 'http://dummyimage.com'
gravatarImageUrl: 'http://dummyimage.com',
lang: 'en',
lastLogin: new Date().toISOString()
};

vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
if (url === ApiConfig.refreshTokenUrl) {
return fakeTokenResponse;
}
if (url === ApiConfig.currentUserUrl) {
return fakeCurrentUser;
}
return undefined;
});

// Store API_TOKEN so that fetchCurrentSession runs the refresh logic.
localStorage.setItem(API_TOKEN, 'dummy');
Expand All @@ -283,8 +309,9 @@ describe('AuthProvider', () => {

await user.click(getByTestIdFunction('checkCurrentAuthUser'));

await waitFor(() =>
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token')
);
await waitFor(() => {
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token');
expect(localStorage.getItem(USER_DATA)).toContain('Refreshed User');
});
});
});
4 changes: 3 additions & 1 deletion client/src/api-service/apiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ const ApiConfig = {

publicNotesUrl: `${server}/public/notes`,

userUrl: `${server}/rest/users`
userUrl: `${server}/rest/users`,

currentUserUrl: `${server}/rest/users/me`
};

export default ApiConfig;
5 changes: 3 additions & 2 deletions client/src/context/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
}
try {
const bearerToken: SignInResponse = await api.getJSON(ApiConfig.refreshTokenUrl);
setSigned(true);
return bearerToken;
}
catch (e) {
Expand Down Expand Up @@ -66,8 +65,10 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
const checkCurrentAuthUser = async (pathname: string): Promise<void> => {
const bearerToken: SignInResponse | undefined = await fetchCurrentSession(pathname);
if (bearerToken && bearerToken.token) {
const userLocal = updateUserSession(null, bearerToken.token);
const currentUser: UserResponse = await api.getJSON(ApiConfig.currentUserUrl);
const userLocal = updateUserSession(currentUser, bearerToken.token);
if (userLocal) {
setSigned(true);
setUser(userLocal);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ public List<UserResponse> getAllUsers() {
return authService.getAllUsers();
}

/**
* Get the current logged user.
*
* @return UserEntity with the current user information.
*/
@GetMapping("/me")
public UserResponse getCurrentUser() {
return authService.getCurrentUserResponse();
}

@PatchMapping
public ResponseEntity<UserResponse> patchUserInfo(
@RequestBody @Valid UserPatchRequest taskRequest) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,17 @@ public Optional<UserEntity> getCurrentUser() {
return findByEmail(email);
}

/**
* Get the current logged user as response object.
*
* @return An instance of {@link UserResponse} with the current user.
* @throws UserNotFoundException when the user was not found
*/
public UserResponse getCurrentUserResponse() {
UserEntity user = getCurrentUser().orElseThrow(UserNotFoundException::new);
return UserResponse.fromEntity(user, getGravatarImageUrl(user.getEmail()).orElse(null));
}

/**
* Confirm a user account.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,20 @@ public class TimeAgoUtil {
* @return String with formatted time.
*/
public static String format(LocalDateTime pastTime) {
return format(pastTime, LocalDateTime.now());
}

/**
* Format a time in the time ago format.
*
* @param pastTime The pastime to be formatted.
* @param now The current time.
* @return String with formatted time.
*/
public static String format(LocalDateTime pastTime, LocalDateTime now) {
if (Objects.isNull(pastTime)) {
return "Some time ago";
}
LocalDateTime now = LocalDateTime.now();
Period period = Period.between(pastTime.toLocalDate(), now.toLocalDate());
Duration duration = Duration.between(pastTime, now);
if (period.getYears() > 1) {
Expand All @@ -32,10 +42,12 @@ public static String format(LocalDateTime pastTime) {
return String.format("%d months ago", period.getMonths());
} else if (period.getMonths() > 0) {
return String.format("%d month ago", period.getMonths());
} else if (period.getDays() > 1) {
return String.format("%d days ago", period.getDays());
} else if (period.getDays() > 0) {
return String.format("%d day ago", period.getDays());
} else if (duration.toHours() >= 24) {
if (period.getDays() > 1) {
return String.format("%d days ago", period.getDays());
} else {
return String.format("%d day ago", period.getDays());
}
} else if (duration.toHours() > 1L) {
return String.format("%d hours ago", duration.toHours());
} else if (duration.toHours() > 0L) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,40 @@ void getAllUsers_unauthorized_shouldFail() throws Exception {
.andReturn();
}

@Test
@DisplayName("Get current user happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void getCurrentUser_happyPath_shouldSucceed() throws Exception {
UserResponse userResponse =
new UserResponse(1L, "John", "email@test.com", false, null, null, null, null);
when(authService.getCurrentUserResponse()).thenReturn(userResponse);

mockMvc
.perform(
get("/rest/users/me")
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(userResponse.userId()))
.andExpect(jsonPath("$.email").value(userResponse.email()))
.andExpect(jsonPath("$.admin").value(userResponse.admin()))
.andReturn();
}

@Test
@DisplayName("Get current user with 401 unauthorized request should fail")
void getCurrentUser_unauthorized_shouldFail() throws Exception {
mockMvc
.perform(
get("/rest/users/me")
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andReturn();
}

@Test
@DisplayName("Patch user info happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ void formatTest() {
"2 months ago", TimeAgoUtil.format(LocalDateTime.now().minusMonths(2L)));
}

@Test
void formatMidnightRolloverTest() {
LocalDateTime now = LocalDateTime.of(2026, 5, 19, 0, 30); // 12:30 AM next day
LocalDateTime pastTime = now.minusHours(1); // 11:30 PM previous day
Assertions.assertEquals("1 hour ago", TimeAgoUtil.format(pastTime, now));

// Test exactly 24 hours ago
LocalDateTime twentyFourHoursAgo = now.minusDays(1);
Assertions.assertEquals("1 day ago", TimeAgoUtil.format(twentyFourHoursAgo, now));

// Test 23 hours ago across midnight
LocalDateTime twentyThreeHoursAgo = now.minusHours(23); // 1:30 AM previous day
Assertions.assertEquals("23 hours ago", TimeAgoUtil.format(twentyThreeHoursAgo, now));
}

@Test
void formatDueDateTest() {
Assertions.assertNull(TimeAgoUtil.formatDueDate(null));
Expand Down
Loading