diff --git a/backend/pom.xml b/backend/pom.xml
index fd5f17a..3830b3f 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -79,6 +79,10 @@
h2
test
+
+ org.awaitility
+ awaitility
+
diff --git a/backend/src/main/java/com/github/binpastes/paste/api/PasteController.java b/backend/src/main/java/com/github/binpastes/paste/api/PasteController.java
index b094fe7..554540b 100644
--- a/backend/src/main/java/com/github/binpastes/paste/api/PasteController.java
+++ b/backend/src/main/java/com/github/binpastes/paste/api/PasteController.java
@@ -14,24 +14,35 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.HttpHeaders;
+import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.TimeUnit;
import static com.github.binpastes.paste.api.model.ListView.ListItemView;
@Validated
@RestController
-@RequestMapping("/api/v1/paste")
+@RequestMapping({"/api/v1/paste", "/api/v1/paste/"})
class PasteController {
private static final Logger log = LoggerFactory.getLogger(PasteController.class);
@@ -49,23 +60,26 @@ public Mono findPaste(@PathVariable("pasteId") String pasteId, Serve
.find(pasteId)
.doOnNext(paste -> {
if (paste.isOneTime()) {
- response.getHeaders().add(HttpHeaders.CACHE_CONTROL, "no-store");
- } else {
- if (!paste.isPermanent()) {
- var in5min = LocalDateTime.now().plusMinutes(5);
- if (in5min.isAfter(paste.getDateOfExpiry())) {
- response.getHeaders().add(HttpHeaders.CACHE_CONTROL, "max-age=" + Duration.between(LocalDateTime.now(), paste.getDateOfExpiry()).toSeconds());
- return;
- }
- }
-
- response.getHeaders().add(HttpHeaders.CACHE_CONTROL, "max-age=300");
+ response.getHeaders().setCacheControl(CacheControl.noStore());
+ return;
}
+
+ if (paste.isPermanent() || isAfter(paste.getDateOfExpiry(), 5, ChronoUnit.MINUTES)) {
+ response.getHeaders().setCacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES));
+ return;
+ }
+
+ response.getHeaders().setCacheControl(
+ CacheControl.maxAge(Duration.between(LocalDateTime.now(), paste.getDateOfExpiry())));
})
.map(reference -> SingleView.of(reference, remoteAddress(request)))
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)));
}
+ private static boolean isAfter(LocalDateTime dateTime, long amount, ChronoUnit unit) {
+ return LocalDateTime.now().plus(amount, unit).isBefore(dateTime);
+ }
+
@GetMapping
public Mono findPastes() {
return pasteService
@@ -83,7 +97,7 @@ public Mono searchPastes(
final String term,
final ServerHttpResponse response
) {
- response.getHeaders().add(HttpHeaders.CACHE_CONTROL, "max-age=60");
+ response.getHeaders().setCacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS));
return pasteService
.findByFullText(term)
.map(paste -> SearchItemView.of(paste, term))
@@ -129,5 +143,4 @@ private static String remoteAddress(ServerHttpRequest request) {
return request.getRemoteAddress().getAddress().getHostAddress();
}
-
}
diff --git a/backend/src/main/java/com/github/binpastes/paste/business/tracking/TrackingService.java b/backend/src/main/java/com/github/binpastes/paste/business/tracking/TrackingService.java
index d71240f..1382fac 100644
--- a/backend/src/main/java/com/github/binpastes/paste/business/tracking/TrackingService.java
+++ b/backend/src/main/java/com/github/binpastes/paste/business/tracking/TrackingService.java
@@ -1,6 +1,5 @@
package com.github.binpastes.paste.business.tracking;
-import com.github.binpastes.paste.business.tracking.MessagingClient.Message;
import com.github.binpastes.paste.domain.PasteRepository;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
@@ -35,7 +34,7 @@ public TrackingService(
private void run() {
this.messagingClient
.receiveMessage()
- .doOnNext(this::receiveView)
+ .doOnNext(message -> receiveView(message.pasteId(), message.timeViewed()))
.repeat()
.subscribe();
}
@@ -55,8 +54,4 @@ public void receiveView(String pasteId, Instant timeViewed) {
.onErrorResume(OptimisticLockingFailureException.class, e -> Mono.empty())
.subscribe();
}
-
- private void receiveView(Message message) {
- this.receiveView(message.pasteId(), message.timeViewed());
- }
}
diff --git a/backend/src/main/java/com/github/binpastes/paste/domain/Paste.java b/backend/src/main/java/com/github/binpastes/paste/domain/Paste.java
index 48592db..ff4b18e 100644
--- a/backend/src/main/java/com/github/binpastes/paste/domain/Paste.java
+++ b/backend/src/main/java/com/github/binpastes/paste/domain/Paste.java
@@ -11,6 +11,7 @@
import java.util.Objects;
import static com.github.binpastes.paste.domain.Paste.PasteSchema;
+import static java.util.Objects.isNull;
@Table(PasteSchema.TABLE_NAME)
public class Paste {
@@ -123,15 +124,15 @@ public boolean isPermanent() {
}
public boolean isErasable(String remoteAddress) {
- if (this.isUnlisted() || this.isOneTime()) {
+ if (isUnlisted() || isOneTime()) {
return true;
}
- if (this.isPublic()) {
- final var createdBySameAuthor = Objects.equals(remoteAddress, this.getRemoteAddress());
+ if (isPublic()) {
+ final var createdBySameAuthor = Objects.equals(remoteAddress, getRemoteAddress());
if (createdBySameAuthor) {
- return LocalDateTime.now().minusHours(1).isBefore(this.getDateCreated());
+ return LocalDateTime.now().minusHours(1).isBefore(getDateCreated());
}
}
@@ -139,15 +140,19 @@ public boolean isErasable(String remoteAddress) {
}
public Paste trackView(LocalDateTime lastViewed) {
- if (this.getLastViewed() == null || this.getLastViewed().isBefore(lastViewed)) {
+ if (getLastViewed() == null || getLastViewed().isBefore(lastViewed)) {
setLastViewed(lastViewed);
}
- return this.setViews(this.getViews() + 1);
+ return setViews(this.getViews() + 1);
}
public Paste markAsExpired() {
- return this.setDateOfExpiry(LocalDateTime.now());
+ if (isNull(getDateOfExpiry())) {
+ return setDateOfExpiry(LocalDateTime.now());
+ }
+
+ throw new IllegalStateException("Paste is already expired");
}
protected Paste setId(final String id) {
diff --git a/backend/src/test/java/com/github/binpastes/paste/api/OneTimePastesIT.java b/backend/src/test/java/com/github/binpastes/paste/api/OneTimePastesIT.java
new file mode 100644
index 0000000..2017a96
--- /dev/null
+++ b/backend/src/test/java/com/github/binpastes/paste/api/OneTimePastesIT.java
@@ -0,0 +1,119 @@
+package com.github.binpastes.paste.api;
+
+import com.github.binpastes.paste.domain.Paste;
+import com.github.binpastes.paste.domain.PasteRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.CacheControl;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import static java.util.Collections.emptyList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@AutoConfigureWebTestClient
+class OneTimePastesIT {
+
+ @Autowired
+ private WebTestClient webClient;
+
+ @Autowired
+ private PasteRepository pasteRepository;
+
+ @BeforeEach
+ void setUp() {
+ pasteRepository.deleteAll().block();
+ }
+
+ @Test
+ @DisplayName("GET /{pasteId} - one-time paste is never cached")
+ void getOneTimePaste() {
+ var oneTimePaste = givenOneTimePaste();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + oneTimePaste.getId())
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().cacheControl(CacheControl.noStore());
+ }
+
+ @Test
+ @DisplayName("GET /{pasteId} - one-time paste is read-once")
+ void getOneTimePasteTwice() {
+ var oneTimePaste = givenOneTimePaste();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + oneTimePaste.getId())
+ .exchange()
+ .expectStatus().isOk();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + oneTimePaste.getId())
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ @DisplayName("GET / - one-time paste is never listed")
+ void findAllPastes() {
+ givenOneTimePaste();
+
+ assertThat(pasteRepository.count().block()).isOne();
+ webClient.get()
+ .uri("/api/v1/paste/")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody().jsonPath("pastes", emptyList());
+ }
+
+ @Test
+ @DisplayName("GET /search - one-time paste cannot be searched for")
+ void searchAllPastes() {
+ givenOneTimePaste();
+
+ assertThat(pasteRepository.count().block()).isOne();
+ webClient.get()
+ .uri("/api/v1/paste/search?term={term}", "ipsum")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody().jsonPath("pastes", emptyList());
+ }
+
+ @Test
+ @DisplayName("DELETE /{pasteId} - one-time paste might always be deleted before reading")
+ void deleteOneTimePaste() {
+ var oneTimePaste = givenOneTimePaste();
+
+ webClient.delete()
+ .uri("/api/v1/paste/" + oneTimePaste.getId())
+ .exchange()
+ .expectStatus().isNoContent()
+ .expectBody().isEmpty();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + oneTimePaste.getId())
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ private Paste givenOneTimePaste() {
+ return givenOneTimePaste(
+ Paste.newInstance(
+ "someTitle",
+ "Lorem ipsum dolor sit amet",
+ null,
+ false,
+ Paste.PasteExposure.ONCE,
+ "1.1.1.1"
+ )
+ );
+ }
+
+ private Paste givenOneTimePaste(Paste paste) {
+ return pasteRepository.save(paste).block();
+ }
+}
diff --git a/backend/src/test/java/com/github/binpastes/paste/api/PasteControllerTest.java b/backend/src/test/java/com/github/binpastes/paste/api/PasteControllerTest.java
index dabed7b..40383d2 100644
--- a/backend/src/test/java/com/github/binpastes/paste/api/PasteControllerTest.java
+++ b/backend/src/test/java/com/github/binpastes/paste/api/PasteControllerTest.java
@@ -10,7 +10,6 @@
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.CacheControl;
-import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
@@ -45,7 +44,7 @@ void findUnknownPaste() {
.uri("/api/v1/paste/" + somePasteId)
.exchange()
.expectStatus().isNotFound()
- .expectHeader().doesNotExist(HttpHeaders.CACHE_CONTROL);
+ .expectHeader().cacheControl(CacheControl.empty());
}
@Test
@@ -92,8 +91,7 @@ void createPaste(Mono payload) {
.contentType(MediaType.APPLICATION_JSON)
.body(payload, String.class)
.exchange()
- .expectStatus().isBadRequest()
- .expectBody().consumeWith(System.out::println);
+ .expectStatus().isBadRequest();
}
private static Stream invalidPayloads() {
diff --git a/backend/src/test/java/com/github/binpastes/paste/api/PublicPastesIT.java b/backend/src/test/java/com/github/binpastes/paste/api/PublicPastesIT.java
new file mode 100644
index 0000000..564b500
--- /dev/null
+++ b/backend/src/test/java/com/github/binpastes/paste/api/PublicPastesIT.java
@@ -0,0 +1,121 @@
+package com.github.binpastes.paste.api;
+
+import com.github.binpastes.paste.domain.Paste;
+import com.github.binpastes.paste.domain.PasteRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@AutoConfigureWebTestClient
+class PublicPastesIT {
+
+ @Autowired
+ private WebTestClient webClient;
+
+ @Autowired
+ private PasteRepository pasteRepository;
+
+ @BeforeEach
+ void setUp() {
+ pasteRepository.deleteAll().block();
+ }
+
+ @Test
+ @DisplayName("GET /{pasteId} - public paste is cached")
+ void getOneTimePaste() {
+ var paste = givenPublicPaste();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + paste.getId())
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES));
+ }
+
+ @Test
+ @DisplayName("GET /{pasteId} - public paste is cached until expiry")
+ void getExpiringOneTimePaste() {
+ var paste = givenPublicPaste(Paste.newInstance(
+ "someTitle",
+ "Lorem ipsum dolor sit amet",
+ LocalDateTime.now().plusMinutes(5).minusSeconds(1),
+ false,
+ Paste.PasteExposure.PUBLIC,
+ "1.1.1.1"
+ ));
+
+ webClient.get()
+ .uri("/api/v1/paste/" + paste.getId())
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().value(
+ HttpHeaders.CACHE_CONTROL,
+ (value) -> assertThat(Long.valueOf(value.replace("max-age=", "")))
+ .isLessThanOrEqualTo(TimeUnit.MINUTES.toSeconds(5)));
+ }
+
+ @Test
+ @DisplayName("GET / - public paste is listed")
+ void findAllPastes() {
+ givenPublicPaste();
+
+ assertThat(pasteRepository.count().block()).isOne();
+ webClient.get()
+ .uri("/api/v1/paste/")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody().jsonPath("$.pastes.length()", 1);
+ }
+
+ @Test
+ @DisplayName("DELETE /{pasteId} - public paste might be deleted")
+ void deleteOneTimePasteBySameAuthor() {
+ var paste = givenPublicPaste();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + paste.getId())
+ .exchange()
+ .expectStatus().isOk();
+
+ webClient.delete()
+ .uri("/api/v1/paste/" + paste.getId())
+ .header("X-Forwarded-For", "someAuthor")
+ .exchange()
+ .expectStatus().isNoContent()
+ .expectBody().isEmpty();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + paste.getId())
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ private Paste givenPublicPaste() {
+ return givenPublicPaste(
+ Paste.newInstance(
+ "someTitle",
+ "Lorem ipsum dolor sit amet",
+ null,
+ false,
+ Paste.PasteExposure.PUBLIC,
+ "someAuthor"
+ )
+ );
+ }
+
+ private Paste givenPublicPaste(Paste paste) {
+ return pasteRepository.save(paste).block();
+ }
+}
diff --git a/backend/src/test/java/com/github/binpastes/paste/api/SearchPastesIT.java b/backend/src/test/java/com/github/binpastes/paste/api/SearchPastesIT.java
new file mode 100644
index 0000000..12bbdd1
--- /dev/null
+++ b/backend/src/test/java/com/github/binpastes/paste/api/SearchPastesIT.java
@@ -0,0 +1,77 @@
+package com.github.binpastes.paste.api;
+
+import com.github.binpastes.paste.domain.Paste;
+import com.github.binpastes.paste.domain.PasteRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@AutoConfigureWebTestClient
+class SearchPastesIT {
+
+ @Autowired
+ private WebTestClient webClient;
+
+ @Autowired
+ private PasteRepository pasteRepository;
+
+ @BeforeEach
+ void setUp() {
+ pasteRepository.deleteAll().block();
+ }
+
+ @Test
+ @DisplayName("GET /search - paste is found if text matches")
+ void findIfMatch() {
+ givenPublicPaste();
+
+ assertThat(pasteRepository.count().block()).isOne();
+ webClient.get()
+ .uri("/api/v1/paste/search?term={term}", "ipsum")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody().jsonPath("$.pastes.length()", 1);
+ }
+
+ @Test
+ @DisplayName("GET /search - paste is not found if no match")
+ void findNothing() {
+ givenPublicPaste();
+
+ assertThat(pasteRepository.count().block()).isOne();
+ webClient.get()
+ .uri("/api/v1/paste/search?term={term}", "foobar")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody().jsonPath("$.pastes.length()", 0);
+ }
+
+ private Paste givenPublicPaste() {
+ return givenPublicPaste(
+ Paste.newInstance(
+ "someTitle",
+ "Lorem ipsum dolor sit amet",
+ null,
+ false,
+ Paste.PasteExposure.PUBLIC,
+ "someAuthor"
+ )
+ );
+ }
+
+ private Paste givenPublicPaste(Paste paste) {
+ return pasteRepository.save(paste).block();
+ }
+}
diff --git a/backend/src/test/java/com/github/binpastes/paste/api/TrackingIT.java b/backend/src/test/java/com/github/binpastes/paste/api/TrackingIT.java
new file mode 100644
index 0000000..c10f1e4
--- /dev/null
+++ b/backend/src/test/java/com/github/binpastes/paste/api/TrackingIT.java
@@ -0,0 +1,121 @@
+package com.github.binpastes.paste.api;
+
+import com.github.binpastes.paste.business.tracking.MessagingClient;
+import com.github.binpastes.paste.business.tracking.TrackingService;
+import com.github.binpastes.paste.domain.Paste;
+import com.github.binpastes.paste.domain.Paste.PasteExposure;
+import com.github.binpastes.paste.domain.PasteRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import reactor.core.publisher.Flux;
+import reactor.core.scheduler.Schedulers;
+
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.waitAtMost;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.springframework.test.util.ReflectionTestUtils.setField;
+
+@SpringBootTest
+@AutoConfigureWebTestClient
+class TrackingIT {
+
+ @Autowired
+ private WebTestClient webClient;
+
+ @Autowired
+ private PasteRepository pasteRepository;
+
+ @Autowired
+ private TrackingService trackingService;
+
+ @SpyBean
+ private MessagingClient messagingClient;
+
+ @BeforeEach
+ void setUp() {
+ pasteRepository.deleteAll().block();
+ }
+
+ @Test
+ @DisplayName("tracking - paste is tracked on direct access eventually")
+ void trackPaste() {
+ var paste = givenPublicPaste();
+
+ assertThat(paste.getViews()).isZero();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + paste.getId())
+ .exchange()
+ .expectStatus().isOk();
+
+ waitAtMost(2, TimeUnit.SECONDS).until(() -> {
+ var latestPaste = pasteRepository.findById(paste.getId()).block();
+ return latestPaste.getViews() == 1;
+ });
+ }
+
+ @Test
+ @DisplayName("tracking - deal with concurrent tracking events")
+ void trackConcurrentPasteViews() {
+ var intialPaste = givenPublicPaste();
+
+ Flux.fromStream(Stream.generate(intialPaste::getId))
+ .take(1000)
+ .doOnNext(trackingService::trackView)
+ // enforce a concurrent update
+ .doOnNext(id -> pasteRepository.findById(id)
+ .doOnNext(paste -> setField(paste, "remoteAddress", "concurrentUpdate"))
+ .flatMap(paste -> pasteRepository.save(paste))
+ .onErrorComplete() // ignore errors in test
+ .subscribe())
+ .subscribeOn(Schedulers.parallel())
+ .subscribe();
+
+ waitAtMost(5, TimeUnit.SECONDS)
+ .until(
+ () -> pasteRepository.findById(intialPaste.getId()).block().getViews(),
+ equalTo(1000L)
+ );
+
+ Mockito.verify(messagingClient, Mockito.atLeast(1001)).sendMessage(any(), any());
+ }
+
+ @Test
+ @DisplayName("tracking - unknown paste is not repeatedly tracked")
+ void trackUnknownPaste() throws InterruptedException {
+ trackingService.trackView("4711");
+ TimeUnit.SECONDS.sleep(1);
+
+ Mockito.verify(messagingClient).sendMessage(eq("4711"), any());
+ Mockito.verify(messagingClient).receiveMessage(); // TODOO
+ }
+
+ private Paste givenPublicPaste() {
+ return givenPublicPaste(
+ Paste.newInstance(
+ "someTitle",
+ "Lorem ipsum dolor sit amet",
+ null,
+ false,
+ PasteExposure.PUBLIC,
+ "someAuthor"
+ )
+ );
+ }
+
+ private Paste givenPublicPaste(Paste paste) {
+ return pasteRepository.save(paste).block();
+ }
+}
diff --git a/backend/src/test/java/com/github/binpastes/paste/api/UnlistedPastesIT.java b/backend/src/test/java/com/github/binpastes/paste/api/UnlistedPastesIT.java
new file mode 100644
index 0000000..fc402a2
--- /dev/null
+++ b/backend/src/test/java/com/github/binpastes/paste/api/UnlistedPastesIT.java
@@ -0,0 +1,134 @@
+package com.github.binpastes.paste.api;
+
+import com.github.binpastes.paste.domain.Paste;
+import com.github.binpastes.paste.domain.PasteRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.TimeUnit;
+
+import static java.util.Collections.emptyList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@AutoConfigureWebTestClient
+class UnlistedPastesIT {
+
+ @Autowired
+ private WebTestClient webClient;
+
+ @Autowired
+ private PasteRepository pasteRepository;
+
+ @BeforeEach
+ void setUp() {
+ pasteRepository.deleteAll().block();
+ }
+
+ @Test
+ @DisplayName("GET /{pasteId} - unlisted paste is cached")
+ void getOneTimePaste() {
+ var unlistedPaste = givenUnlistedPaste();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + unlistedPaste.getId())
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES));
+ }
+
+ @Test
+ @DisplayName("GET /{pasteId} - unlisted paste is cached until expiry")
+ void getExpiringOneTimePaste() {
+ var unlistedPaste = givenUnlistedPaste(Paste.newInstance(
+ "someTitle",
+ "Lorem ipsum dolor sit amet",
+ LocalDateTime.now().plusMinutes(5).minusSeconds(1),
+ false,
+ Paste.PasteExposure.UNLISTED,
+ "1.1.1.1"
+ ));
+
+ webClient.get()
+ .uri("/api/v1/paste/" + unlistedPaste.getId())
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().value(
+ HttpHeaders.CACHE_CONTROL,
+ (value) -> assertThat(Long.valueOf(value.replace("max-age=", "")))
+ .isLessThanOrEqualTo(TimeUnit.MINUTES.toSeconds(5)));
+ }
+
+ @Test
+ @DisplayName("GET / - unlisted paste is never listed")
+ void findAllPastes() {
+ givenUnlistedPaste();
+
+ assertThat(pasteRepository.count().block()).isOne();
+ webClient.get()
+ .uri("/api/v1/paste/")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody().jsonPath("pastes", emptyList());
+ }
+
+ @Test
+ @DisplayName("GET /search - unlisted paste cannot be searched for")
+ void searchAllPastes() {
+ givenUnlistedPaste();
+
+ assertThat(pasteRepository.count().block()).isOne();
+ webClient.get()
+ .uri("/api/v1/paste/search?term={term}", "ipsum")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody().jsonPath("pastes", emptyList());
+ }
+
+ @Test
+ @DisplayName("DELETE /{pasteId} - unlisted paste might always be deleted")
+ void deleteOneTimePaste() {
+ var unlistedPaste = givenUnlistedPaste();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + unlistedPaste.getId())
+ .exchange()
+ .expectStatus().isOk();
+
+ webClient.delete()
+ .uri("/api/v1/paste/" + unlistedPaste.getId())
+ .exchange()
+ .expectStatus().isNoContent()
+ .expectBody().isEmpty();
+
+ webClient.get()
+ .uri("/api/v1/paste/" + unlistedPaste.getId())
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ private Paste givenUnlistedPaste() {
+ return givenUnlistedPaste(
+ Paste.newInstance(
+ "someTitle",
+ "Lorem ipsum dolor sit amet",
+ null,
+ false,
+ Paste.PasteExposure.UNLISTED,
+ "1.1.1.1"
+ )
+ );
+ }
+
+ private Paste givenUnlistedPaste(Paste paste) {
+ return pasteRepository.save(paste).block();
+ }
+}