From 3340263e4d9114b805e238fd9233d666b14f26c8 Mon Sep 17 00:00:00 2001 From: Stefan Wilke Date: Tue, 21 Nov 2023 08:56:18 +0100 Subject: [PATCH] add integration tests --- .github/workflows/ci.yml | 7 +- backend/pom.xml | 16 ++ .../binpastes/paste/api/PasteController.java | 45 ++++-- .../business/tracking/TrackingService.java | 7 +- .../github/binpastes/paste/domain/Paste.java | 23 +-- .../binpastes/paste/domain/PasteService.java | 8 +- .../com/github/binpastes/BinPastesTests.java | 12 -- .../binpastes/paste/api/OneTimePastesIT.java | 122 ++++++++++++++++ .../paste/api/PasteControllerTest.java | 15 +- .../binpastes/paste/api/PublicPastesIT.java | 124 ++++++++++++++++ .../binpastes/paste/api/SearchPastesIT.java | 74 ++++++++++ .../binpastes/paste/api/TrackingIT.java | 125 ++++++++++++++++ .../binpastes/paste/api/UnlistedPastesIT.java | 137 ++++++++++++++++++ frontend/pom.xml | 10 ++ 14 files changed, 666 insertions(+), 59 deletions(-) delete mode 100644 backend/src/test/java/com/github/binpastes/BinPastesTests.java create mode 100644 backend/src/test/java/com/github/binpastes/paste/api/OneTimePastesIT.java create mode 100644 backend/src/test/java/com/github/binpastes/paste/api/PublicPastesIT.java create mode 100644 backend/src/test/java/com/github/binpastes/paste/api/SearchPastesIT.java create mode 100644 backend/src/test/java/com/github/binpastes/paste/api/TrackingIT.java create mode 100644 backend/src/test/java/com/github/binpastes/paste/api/UnlistedPastesIT.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0353c8a..2fe1119 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: jobs: build: runs-on: ubuntu-latest + permissions: write-all steps: - name: Checkout repository uses: actions/checkout@v4 @@ -18,7 +19,11 @@ jobs: java-version: '17' - name: Build with Maven - run: mvn clean package + run: mvn clean verify + + - name: Publish Test Report + if: success() || failure() + uses: scacap/action-surefire-report@v1 release: runs-on: ubuntu-latest diff --git a/backend/pom.xml b/backend/pom.xml index fd5f17a..204856b 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -79,6 +79,10 @@ h2 test + + org.awaitility + awaitility + @@ -107,6 +111,18 @@ + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + org.apache.maven.plugins maven-deploy-plugin 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..1252b6a 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 { @@ -119,19 +120,19 @@ public boolean isOneTime() { } public boolean isPermanent() { - return this.dateOfExpiry == null; + return isNull(this.dateOfExpiry); } 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 (isNull(getLastViewed()) || 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) { @@ -209,7 +214,7 @@ protected Paste setViews(final long views) { @Override public boolean equals(final Object o) { if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (isNull(o) || getClass() != o.getClass()) return false; Paste paste = (Paste) o; return Objects.equals(id, paste.id); } diff --git a/backend/src/main/java/com/github/binpastes/paste/domain/PasteService.java b/backend/src/main/java/com/github/binpastes/paste/domain/PasteService.java index 0dd74f9..7ac8789 100644 --- a/backend/src/main/java/com/github/binpastes/paste/domain/PasteService.java +++ b/backend/src/main/java/com/github/binpastes/paste/domain/PasteService.java @@ -48,14 +48,14 @@ public Mono find(String id) { } private Mono trackAccess(Paste paste) { - trackingService.trackView(paste.getId()); - if (paste.isOneTime()) { return pasteRepository .save(paste.markAsExpired()) - .doOnSuccess(deletedPaste -> log.info("OneTime paste {} viewed and burnt", deletedPaste.getId())); + .doOnSuccess(deletedPaste -> log.info("OneTime paste {} viewed and burnt", deletedPaste.getId())) + .onErrorComplete(); } + trackingService.trackView(paste.getId()); return Mono.just(paste); } @@ -66,7 +66,7 @@ public Flux findAll() { public Flux findByFullText(String text) { return pasteRepository .searchAllLegitByFullText(text) - // TODO remove when fulltext search is finalised + // TODO remove when fulltext search is 'good enough' .collectList() .doOnSuccess(pastes -> log.info("Found {} pastes searching for: {}", pastes.size(), text)) .flatMapMany(Flux::fromIterable); diff --git a/backend/src/test/java/com/github/binpastes/BinPastesTests.java b/backend/src/test/java/com/github/binpastes/BinPastesTests.java deleted file mode 100644 index fa27f05..0000000 --- a/backend/src/test/java/com/github/binpastes/BinPastesTests.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.binpastes; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class BinPastesTests { - - @Test - void contextLoads() {} - -} 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..89a9e11 --- /dev/null +++ b/backend/src/test/java/com/github/binpastes/paste/api/OneTimePastesIT.java @@ -0,0 +1,122 @@ +package com.github.binpastes.paste.api; + +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.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.annotation.DirtiesContext; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@AutoConfigureWebTestClient +@DirtiesContext +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 givenPaste( + Paste.newInstance( + "someTitle", + "Lorem ipsum dolor sit amet", + null, + false, + PasteExposure.ONCE, + "1.1.1.1" + ) + ); + } + + private Paste givenPaste(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..e7c585a 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 @@ -69,7 +68,7 @@ void searchPastes() { .uri("/api/v1/paste/search?term={term}", "foobar") .exchange() .expectStatus().isOk() - .expectHeader().cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS)) + .expectHeader().cacheControl(CacheControl.maxAge(1, TimeUnit.MINUTES)) .expectBody().jsonPath("pastes", emptyList()); } @@ -92,20 +91,14 @@ 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() { return Stream.of( arguments(named("body is null", Mono.empty())), arguments(named("body blank", Mono.just(""))), - arguments(named("title too long", Mono.just(""" - { - "title": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "content": "validContent" - } - """))), + arguments(named("title too long", Mono.just("{\"content\": \"validContent\", \"title\": " + "X".repeat(256 + 1) + "\"}"))), arguments(named("content blank", Mono.just(""" { "content": " ", 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..f6f10b3 --- /dev/null +++ b/backend/src/test/java/com/github/binpastes/paste/api/PublicPastesIT.java @@ -0,0 +1,124 @@ +package com.github.binpastes.paste.api; + +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.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.annotation.DirtiesContext; +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 +@DirtiesContext +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 getPublicPaste() { + 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 getExpiringPublicPaste() { + var paste = givenPaste(Paste.newInstance( + "someTitle", + "Lorem ipsum dolor sit amet", + LocalDateTime.now().plusMinutes(5).minusSeconds(1), // under 5min remaining + false, + 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 deletePublicPaste() { + 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 givenPaste( + Paste.newInstance( + "someTitle", + "Lorem ipsum dolor sit amet", + null, + false, + PasteExposure.PUBLIC, + "someAuthor" + ) + ); + } + + private Paste givenPaste(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..bcfdfe5 --- /dev/null +++ b/backend/src/test/java/com/github/binpastes/paste/api/SearchPastesIT.java @@ -0,0 +1,74 @@ +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.test.annotation.DirtiesContext; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@AutoConfigureWebTestClient +@DirtiesContext +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 givenPaste( + Paste.newInstance( + "someTitle", + "Lorem ipsum dolor sit amet", + null, + false, + Paste.PasteExposure.PUBLIC, + "someAuthor" + ) + ); + } + + private Paste givenPaste(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..c91ed56 --- /dev/null +++ b/backend/src/test/java/com/github/binpastes/paste/api/TrackingIT.java @@ -0,0 +1,125 @@ +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.annotation.DirtiesContext; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +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.mockito.Mockito.atLeast; +import static org.mockito.Mockito.timeout; +import static org.springframework.test.util.ReflectionTestUtils.setField; + +@SpringBootTest +@AutoConfigureWebTestClient +@DirtiesContext +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(Duration.ofSeconds(1)).until( + () -> pasteRepository.findById(paste.getId()).block().getViews(), + equalTo(1L) + ); + } + + @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(); + + await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofMillis(500)).until( + () -> pasteRepository.findById(intialPaste.getId()).block().getViews(), + equalTo(1000L) + ); + + Mockito.verify(messagingClient, atLeast(1001)).sendMessage(any(), any()); + } + + @Test + @DisplayName("tracking - unknown paste is not tracked indefinitely") + void trackUnknownPaste() { + trackingService.trackView("4711"); + + Mockito.verify(messagingClient).sendMessage(eq("4711"), any()); + Mockito.verify(messagingClient, timeout(TimeUnit.SECONDS.toMillis(1))).receiveMessage(); + } + + private Paste givenPublicPaste() { + return givenPaste( + Paste.newInstance( + "someTitle", + "Lorem ipsum dolor sit amet", + null, + false, + PasteExposure.PUBLIC, + "someAuthor" + ) + ); + } + + private Paste givenPaste(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..adc5b85 --- /dev/null +++ b/backend/src/test/java/com/github/binpastes/paste/api/UnlistedPastesIT.java @@ -0,0 +1,137 @@ +package com.github.binpastes.paste.api; + +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.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.annotation.DirtiesContext; +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 +@DirtiesContext +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 getUnlistedPaste() { + 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 only until expiry") + void getExpiringUnlistedPaste() { + var unlistedPaste = givenPaste(Paste.newInstance( + "someTitle", + "Lorem ipsum dolor sit amet", + LocalDateTime.now().plusMinutes(5).minusSeconds(1), + false, + 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 deleteUnlistedPaste() { + 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 givenPaste( + Paste.newInstance( + "someTitle", + "Lorem ipsum dolor sit amet", + null, + false, + PasteExposure.UNLISTED, + "1.1.1.1" + ) + ); + } + + private Paste givenPaste(Paste paste) { + return pasteRepository.save(paste).block(); + } +} diff --git a/frontend/pom.xml b/frontend/pom.xml index b197b1f..0032379 100644 --- a/frontend/pom.xml +++ b/frontend/pom.xml @@ -58,6 +58,16 @@ + + org.apache.maven.plugins + maven-failsafe-plugin + + + default-test + none + + + org.apache.maven.plugins maven-compiler-plugin