diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java index 4cf3717..9702429 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -29,6 +29,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.ZoneOffset; import java.util.*; import java.util.regex.Pattern; @@ -436,7 +437,7 @@ public class ActivityPubController { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().toString()); + noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", activityUri); diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java index 8a582bc..eb0ec99 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import java.time.ZoneOffset; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -199,7 +200,7 @@ public class ActivityPostProcessingService { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().toString()); + noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", baseUrl + "/activities/" + activity.getId()); diff --git a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java index 8dff712..d801143 100644 --- a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java +++ b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java @@ -21,6 +21,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; import java.util.Map; import java.util.UUID; @@ -411,7 +416,7 @@ public class InboxProcessor { // Parse published timestamp String publishedStr = (String) noteObject.get("published"); - Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now(); + Instant publishedAt = parsePublishedAt(publishedStr); // Build RemoteActivity entity RemoteActivity remoteActivity = RemoteActivity.builder() @@ -824,6 +829,44 @@ public class InboxProcessor { } } + /** + * Parse ActivityPub published timestamps. + * + *

Preferred input is a full ISO-8601 instant with timezone/offset. Some + * remote implementations still send zoneless timestamps, so we accept those + * as a compatibility fallback and interpret them as UTC. + */ + private Instant parsePublishedAt(String publishedStr) { + if (publishedStr == null || publishedStr.isBlank()) { + return Instant.now(); + } + + try { + return Instant.parse(publishedStr); + } catch (DateTimeParseException ignored) { + // Fall through to compatibility parsers below. + } + + try { + return OffsetDateTime.parse(publishedStr).toInstant(); + } catch (DateTimeParseException ignored) { + // Fall through to compatibility parsers below. + } + + try { + return ZonedDateTime.parse(publishedStr).toInstant(); + } catch (DateTimeParseException ignored) { + // Fall through to compatibility parsers below. + } + + try { + return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant(); + } catch (DateTimeParseException e) { + log.warn("Failed to parse published timestamp: {}", publishedStr, e); + return Instant.now(); + } + } + /** * Serialize object to JSON string. */ diff --git a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java new file mode 100644 index 0000000..eb52f67 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java @@ -0,0 +1,114 @@ +package net.javahippie.fitpub.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.User; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.UserRepository; +import net.javahippie.fitpub.security.HttpSignatureValidator; +import net.javahippie.fitpub.service.ActivityImageService; +import net.javahippie.fitpub.service.FederationService; +import net.javahippie.fitpub.service.InboxProcessor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.File; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ActivityPubController Tests") +class ActivityPubControllerTest { + + @Mock + private UserRepository userRepository; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private ActivityImageService activityImageService; + + @Mock + private InboxProcessor inboxProcessor; + + @Mock + private FollowRepository followRepository; + + @Mock + private HttpSignatureValidator signatureValidator; + + @Mock + private FederationService federationService; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ActivityPubController controller; + + private UUID activityId; + private UUID userId; + private Activity activity; + private User user; + private LocalDateTime createdAt; + + @BeforeEach + void setUp() { + activityId = UUID.randomUUID(); + userId = UUID.randomUUID(); + createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000); + + ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example"); + + activity = Activity.builder() + .id(activityId) + .userId(userId) + .activityType(Activity.ActivityType.RUN) + .title("Lunch Run") + .description("Sunny run") + .visibility(Activity.Visibility.PUBLIC) + .totalDistance(BigDecimal.valueOf(5000)) + .totalDurationSeconds(1800L) + .createdAt(createdAt) + .build(); + + user = User.builder() + .id(userId) + .username("JaneDoe") + .email("janedoe@example.com") + .publicKey("public-key") + .privateKey("private-key") + .build(); + } + + @Test + @DisplayName("Should serialize activity published timestamp with timezone") + void getActivity_ShouldSerializePublishedTimestampWithTimezone() { + when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image")); + + ResponseEntity> response = controller.getActivity(activityId); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get("published")) + .isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString()); + } +} diff --git a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java index 99e3411..9c5d596 100644 --- a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java +++ b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java @@ -2,19 +2,25 @@ package net.javahippie.fitpub.integration; import com.fasterxml.jackson.databind.ObjectMapper; import net.javahippie.fitpub.config.TestcontainersConfiguration; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.service.ActivityImageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import net.javahippie.fitpub.model.entity.Follow; import net.javahippie.fitpub.model.entity.RemoteActor; +import net.javahippie.fitpub.model.entity.RemoteActivity; import net.javahippie.fitpub.model.entity.User; +import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.RemoteActivityRepository; import net.javahippie.fitpub.repository.RemoteActorRepository; import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.security.HttpSignatureValidator; import net.javahippie.fitpub.security.JwtTokenProvider; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -26,15 +32,21 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Transactional; +import java.io.File; +import java.math.BigDecimal; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.time.LocalDateTime; import java.util.Base64; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -63,6 +75,12 @@ class FederationFollowFlowIntegrationTest { @Autowired private RemoteActorRepository remoteActorRepository; + @Autowired + private RemoteActivityRepository remoteActivityRepository; + + @Autowired + private ActivityRepository activityRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -72,6 +90,9 @@ class FederationFollowFlowIntegrationTest { @Autowired private HttpSignatureValidator signatureValidator; + @MockBean + private ActivityImageService activityImageService; + @Value("${fitpub.base-url}") private String baseUrl; @@ -101,6 +122,22 @@ class FederationFollowFlowIntegrationTest { authToken = jwtTokenProvider.createToken(testUser.getUsername()); } + private User createFederatedUser(String username, String email, String displayName) throws NoSuchAlgorithmException { + KeyPair keyPair = generateRsaKeyPair(); + String publicKey = encodePublicKey(keyPair.getPublic().getEncoded()); + String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded()); + + return userRepository.save(User.builder() + .username(username) + .email(email) + .passwordHash(passwordEncoder.encode("password123")) + .displayName(displayName) + .publicKey(publicKey) + .privateKey(privateKey) + .enabled(true) + .build()); + } + private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); keyGen.initialize(2048); @@ -270,6 +307,107 @@ class FederationFollowFlowIntegrationTest { assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED); } + @Test + @DisplayName("Should import its own exported public activity through inbox") + void testActivityRoundtripThroughExportAndInbox() throws Exception { + User importingUser = testUser; + User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe"); + + Activity activity = activityRepository.save(Activity.builder() + .userId(exportingUser.getId()) + .activityType(Activity.ActivityType.RUN) + .title("Lunch Run") + .description("Sunny run in the city") + .startedAt(LocalDateTime.of(2026, 5, 2, 12, 0)) + .endedAt(LocalDateTime.of(2026, 5, 2, 12, 30)) + .createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000)) + .visibility(Activity.Visibility.PUBLIC) + .totalDistance(BigDecimal.valueOf(5000)) + .totalDurationSeconds(1800L) + .elevationGain(BigDecimal.valueOf(100)) + .sourceFileFormat("FIT") + .published(true) + .build()); + + String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername(); + when(activityImageService.getActivityImageFile(activity.getId())) + .thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image")); + + remoteActorRepository.save(RemoteActor.builder() + .actorUri(exportingActorUri) + .username(exportingUser.getUsername()) + .domain(java.net.URI.create(baseUrl).getHost()) + .displayName(exportingUser.getDisplayName()) + .inboxUrl(exportingActorUri + "/inbox") + .outboxUrl(exportingActorUri + "/outbox") + .publicKey(exportingUser.getPublicKey()) + .publicKeyId(exportingActorUri + "#main-key") + .lastFetchedAt(Instant.now()) + .build()); + + followRepository.save(Follow.builder() + .followerId(importingUser.getId()) + .followingActorUri(exportingActorUri) + .status(Follow.FollowStatus.ACCEPTED) + .activityId(baseUrl + "/activities/follow/" + UUID.randomUUID()) + .build()); + + MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId()) + .accept("application/activity+json")) + .andExpect(status().isOk()) + .andReturn(); + + @SuppressWarnings("unchecked") + Map exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class); + + Map createActivity = Map.of( + "@context", "https://www.w3.org/ns/activitystreams", + "type", "Create", + "id", baseUrl + "/activities/create/" + UUID.randomUUID(), + "actor", exportingActorUri, + "object", exportedNote + ); + + String privateKeyPem = exportingUser.getPrivateKey(); + String inboxPath = "/users/" + importingUser.getUsername() + "/inbox"; + String inboxUrl = "http://localhost" + inboxPath; + String body = objectMapper.writeValueAsString(createActivity); + HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest( + "POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key" + ); + + mockMvc.perform(post(inboxPath) + .contentType("application/activity+json") + .header("Host", sigHeaders.host) + .header("Date", sigHeaders.date) + .header("Digest", sigHeaders.digest) + .header("Signature", sigHeaders.signature) + .content(body)) + .andExpect(status().isAccepted()); + + RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id")) + .orElseThrow(); + + assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id")); + assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri); + assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name", + exportedNote.getOrDefault("summary", "Untitled Activity"))); + assertThat(imported.getDescription()).isEqualTo(stripHtml((String) exportedNote.get("content"))); + assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published"))); + assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC); + assertThat(imported.getActivityType()).isNull(); + assertThat(imported.getTotalDistance()).isNull(); + assertThat(imported.getTotalDurationSeconds()).isNull(); + assertThat(imported.getElevationGain()).isNull(); + assertThat(imported.getAveragePaceSeconds()).isNull(); + assertThat(imported.getAverageHeartRate()).isNull(); + assertThat(imported.getMaxSpeed()).isNull(); + assertThat(imported.getAverageSpeed()).isNull(); + assertThat(imported.getCalories()).isNull(); + assertThat(imported.getMapImageUrl()).isNull(); + assertThat(imported.getTrackGeojsonUrl()).isNull(); + } + @Test @DisplayName("Should reject inbox POST without HTTP signature with 401") void testInboxRejectsUnsignedRequest() throws Exception { @@ -310,6 +448,23 @@ class FederationFollowFlowIntegrationTest { .andExpect(status().isUnauthorized()); } + private String stripHtml(String html) { + if (html == null) { + return ""; + } + return html + .replaceAll("", "\n") + .replaceAll("

", "") + .replaceAll("

", "\n") + .replaceAll("<[^>]+>", "") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") + .trim(); + } + @Test @DisplayName("Should process Undo Follow activity and remove follow relationship") void testProcessUndoFollowActivity() throws Exception { diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java index 08ef492..b2e6c84 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -15,9 +16,11 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.Optional; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -56,11 +59,13 @@ class ActivityPostProcessingServiceTest { private UUID userId; private Activity testActivity; private User testUser; + private LocalDateTime createdAt; @BeforeEach void setUp() { activityId = UUID.randomUUID(); userId = UUID.randomUUID(); + createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000); // Set baseUrl via reflection (since it's @Value injected) ReflectionTestUtils.setField(service, "baseUrl", "https://test.example"); @@ -76,8 +81,8 @@ class ActivityPostProcessingServiceTest { .totalDistance(BigDecimal.valueOf(5000)) .totalDurationSeconds(1800L) .elevationGain(BigDecimal.valueOf(100)) - .startedAt(LocalDateTime.now()) - .createdAt(LocalDateTime.now()) + .startedAt(createdAt.minusMinutes(30)) + .createdAt(createdAt) .build(); // Create test user @@ -232,6 +237,24 @@ class ActivityPostProcessingServiceTest { verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false)); } + @Test + @DisplayName("Should serialize federation note published timestamp with timezone") + void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() { + when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); + when(userRepository.findById(userId)).thenReturn(Optional.of(testUser)); + when(activityImageService.generateActivityImage(testActivity)).thenReturn(null); + doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class); + + service.publishToFederationAsync(activityId, userId); + + verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true)); + assertThat(noteCaptor.getValue().get("published")) + .isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString()); + } + @Test @DisplayName("Should skip federation for PRIVATE activity") void testPublishToFederationAsync_PrivateActivity() { diff --git a/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java b/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java new file mode 100644 index 0000000..2d4f524 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java @@ -0,0 +1,133 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.entity.Follow; +import net.javahippie.fitpub.model.entity.RemoteActor; +import net.javahippie.fitpub.model.entity.User; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.CommentRepository; +import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.LikeRepository; +import net.javahippie.fitpub.repository.RemoteActivityRepository; +import net.javahippie.fitpub.repository.RemoteActorRepository; +import net.javahippie.fitpub.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("InboxProcessor Tests") +class InboxProcessorTest { + + @Mock + private UserRepository userRepository; + + @Mock + private FollowRepository followRepository; + + @Mock + private FederationService federationService; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private LikeRepository likeRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private NotificationService notificationService; + + @Mock + private RemoteActivityRepository remoteActivityRepository; + + @Mock + private RemoteActorRepository remoteActorRepository; + + @InjectMocks + private InboxProcessor inboxProcessor; + + private User localUser; + private String remoteActorUri; + + @BeforeEach + void setUp() { + localUser = User.builder() + .id(UUID.randomUUID()) + .username("JaneDoe") + .email("janedoe@example.com") + .passwordHash("irrelevant") + .publicKey("public-key") + .privateKey("private-key") + .build(); + + remoteActorUri = "https://fitpub.example.com/users/JohnDoe"; + + ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example"); + } + + @Test + @DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone") + void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() { + when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123")) + .thenReturn(false); + when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder() + .actorUri(remoteActorUri) + .username("JohnDoe") + .domain("fitpub.example.com") + .inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox") + .publicKey("public-key") + .build()); + when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser)); + when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri)) + .thenReturn(Optional.of(Follow.builder() + .followerId(localUser.getId()) + .followingActorUri(remoteActorUri) + .status(Follow.FollowStatus.ACCEPTED) + .build())); + + Map note = Map.of( + "id", "https://fitpub.example.com/activities/123", + "type", "Note", + "name", "Lunch Run", + "content", "

Sunny run

", + "published", "2026-05-02T09:24:50.921241", + "to", List.of("https://www.w3.org/ns/activitystreams#Public") + ); + + Map activity = Map.of( + "type", "Create", + "actor", remoteActorUri, + "object", note + ); + + ArgumentCaptor remoteActivityCaptor = + ArgumentCaptor.forClass(net.javahippie.fitpub.model.entity.RemoteActivity.class); + + inboxProcessor.processActivity("JaneDoe", activity); + + verify(remoteActivityRepository).existsByActivityUri("https://fitpub.example.com/activities/123"); + verify(federationService).fetchRemoteActor(remoteActorUri); + verify(remoteActivityRepository).save(remoteActivityCaptor.capture()); + + assertThat(remoteActivityCaptor.getValue().getPublishedAt()) + .isEqualTo(Instant.parse("2026-05-02T09:24:50.921241Z")); + } +}