Refresh auth session with server-authoritative current user data (#59)
* Initial plan * feat: refresh and reload current user profile Agent-Logs-Url: https://github.com/RMCampos/tasknote/sessions/3f01367a-baee-4bbe-8f66-f04da58d485f Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com> * test: fix time zone test issue --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com> Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
This commit is contained in:
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
RMCampos
rmcampos
parent
1bb59dcbc5
commit
f34c359720
@@ -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');
|
||||
@@ -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(
|
||||
@@ -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 () => {
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,16 @@ public class UserController {
|
||||
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) {
|
||||
|
||||
@@ -393,6 +393,17 @@ public class AuthService {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
@@ -32,10 +42,12 @@ public class TimeAgoUtil {
|
||||
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) {
|
||||
|
||||
@@ -63,6 +63,40 @@ class UserControllerTest {
|
||||
.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@")
|
||||
|
||||
@@ -21,6 +21,21 @@ class TimeAgoUtilTest {
|
||||
"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));
|
||||
|
||||
Reference in New Issue
Block a user