Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@
import com.daedan.festabook.festival.domain.Festival;
import com.daedan.festabook.festival.domain.FestivalFixture;
import com.daedan.festabook.festival.infrastructure.FestivalJpaRepository;
import com.daedan.festabook.global.lock.ConcurrencyTestHelper;
import com.daedan.festabook.global.security.JwtTestHelper;
import com.daedan.festabook.global.security.role.RoleType;
import com.daedan.festabook.support.AcceptanceTestSupport;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.http.Header;
import io.restassured.response.Response;
import java.util.List;
import java.util.concurrent.Callable;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
Expand Down Expand Up @@ -453,21 +454,18 @@ class ConcurrentTest {

AnnouncementRequest request = AnnouncementRequestFixture.create(true);

int requestCount = 100;
Runnable httpRequest = () -> {
RestAssured
.given()
.header(authorizationHeader)
.contentType(ContentType.JSON)
.body(request)
.when()
.post("/announcements");
};
Callable<Response> httpRequest = () -> RestAssured
.given()
.header(authorizationHeader)
.contentType(ContentType.JSON)
.body(request)
.when()
.post("/announcements");

int expectedPinnedAnnouncementCount = 3;

// when
ConcurrencyTestHelper.test(requestCount, httpRequest);
concurrencyTestHelper.execute(httpRequest);

// then
Long result = announcementJpaRepository.countByFestivalIdAndIsPinnedTrue(festival.getId());
Expand All @@ -488,31 +486,26 @@ class ConcurrentTest {
AnnouncementRequest createAnnouncement = AnnouncementRequestFixture.create(true);
AnnouncementPinUpdateRequest updateAnnouncement = AnnouncementPinUpdateRequestFixture.create(true);

int requestCount = 100;
Runnable createAnnouncementRequest = () -> {
RestAssured
.given()
.header(authorizationHeader)
.contentType(ContentType.JSON)
.body(createAnnouncement)
.when()
.post("/announcements");
};

Runnable updateAnnouncementRequest = () -> {
RestAssured
.given()
.header(authorizationHeader)
.contentType(ContentType.JSON)
.body(updateAnnouncement)
.when()
.post("announcement/{announcementId}/pin", initAnnouncement.getId());
};
Callable<Response> createAnnouncementRequest = () -> RestAssured
.given()
.header(authorizationHeader)
.contentType(ContentType.JSON)
.body(createAnnouncement)
.when()
.post("/announcements");

Callable<Response> updateAnnouncementRequest = () -> RestAssured
.given()
.header(authorizationHeader)
.contentType(ContentType.JSON)
.body(updateAnnouncement)
.when()
.patch("/announcements/{announcementId}/pin", initAnnouncement.getId());

int expectedPinnedAnnouncementCount = 3;

// when
ConcurrencyTestHelper.test(requestCount, createAnnouncementRequest, updateAnnouncementRequest);
concurrencyTestHelper.execute(createAnnouncementRequest, updateAnnouncementRequest);

// then
Long result = announcementJpaRepository.countByFestivalIdAndIsPinnedTrue(festival.getId());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.daedan.festabook.festival.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.SoftAssertions.assertSoftly;

import com.daedan.festabook.device.domain.Device;
import com.daedan.festabook.device.domain.DeviceFixture;
Expand All @@ -11,12 +11,12 @@
import com.daedan.festabook.festival.dto.FestivalNotificationRequestFixture;
import com.daedan.festabook.festival.infrastructure.FestivalJpaRepository;
import com.daedan.festabook.festival.infrastructure.FestivalNotificationJpaRepository;
import com.daedan.festabook.global.lock.ConcurrencyTestHelper;
import com.daedan.festabook.support.AcceptanceTestSupport;
import com.daedan.festabook.support.ConcurrencyTestResult;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.response.Response;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.Callable;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
Expand Down Expand Up @@ -47,34 +47,28 @@ class subscribeFestivalNotification {

FestivalNotificationRequest request = FestivalNotificationRequestFixture.create(device.getId());

int requestCount = 100;
AtomicInteger duplicateErrorCount = new AtomicInteger(0);

Runnable httpRequest = () -> {
Response response = RestAssured
.given()
.contentType(ContentType.JSON)
.body(request)
.when()
.post("/festivals/{festivalId}/notifications", festival.getId());

if (response.getStatusCode() == HttpStatus.CONFLICT.value()) {
String responseBody = response.getBody().asString();
if (responseBody.contains("FestivalNotification 데이터베이스에 이미 존재합니다.")) {
duplicateErrorCount.incrementAndGet();
}
}
};
Callable<Response> httpRequest = () -> RestAssured
.given()
.contentType(ContentType.JSON)
.body(request)
.when()
.post("/festivals/{festivalId}/notifications", festival.getId());

// when
ConcurrencyTestHelper.test(requestCount, httpRequest);
ConcurrencyTestResult result = concurrencyTestHelper.execute(httpRequest);

// then
Long result = festivalNotificationJpaRepository.countByFestivalIdAndDeviceId(
Long notificationCount = festivalNotificationJpaRepository.countByFestivalIdAndDeviceId(
festival.getId(), device.getId());
assertThat(result).isEqualTo(1);

assertThat(duplicateErrorCount.get()).isEqualTo(99);
int successCount = result.getStatusCodeCount(HttpStatus.CREATED);

assertSoftly(s -> {
s.assertThat(notificationCount).isEqualTo(1);
s.assertThat(successCount).isEqualTo(1);
s.assertThat(result.getStatusCodeCount(HttpStatus.CONFLICT))
.isEqualTo(result.getRequestCount() - successCount);
});
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@Import(TestSecurityConfig.class)
@Import({TestSecurityConfig.class, ConcurrencyTestHelper.class})
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
public abstract class AcceptanceTestSupport {

@Autowired
protected ConcurrencyTestHelper concurrencyTestHelper;

@MockitoBean
protected Clock clock;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.daedan.festabook.support;

import io.restassured.response.Response;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class ConcurrencyTestHelper {

@Value("${server.tomcat.threads.max}")
private int tomcatThreadCount;

@SafeVarargs
public final ConcurrencyTestResult execute(Callable<Response>... requests) {
validateRequests(requests);
ConcurrencyTestResult result = new ConcurrencyTestResult();

try (ExecutorService executorService = Executors.newFixedThreadPool(tomcatThreadCount)) {
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(tomcatThreadCount);

for (int i = 0; i < tomcatThreadCount; i++) {
int currentCount = i % requests.length;
executorService.submit(() -> {
try {
startLatch.await();
Response response = requests[currentCount].call();

HttpStatus httpStatus = HttpStatus.valueOf(response.getStatusCode());
result.recordStatusCode(httpStatus);
} catch (Exception ignore) {
} finally {
endLatch.countDown();
}
});
}

startLatch.countDown();
endLatch.await();
} catch (InterruptedException ignore) {
}
return result;
}

@SafeVarargs
private void validateRequests(Callable<Response>... requests) {
if (requests == null || requests.length == 0) {
throw new IllegalArgumentException("실행할 API 인자는 최소 1개 이상이어야 합니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.daedan.festabook.support;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;

public class ConcurrencyTestResult {

private final Map<HttpStatus, Integer> statusCodeCounts = new ConcurrentHashMap<>();
private final AtomicLong requestCount = new AtomicLong();

public void recordStatusCode(HttpStatus status) {
statusCodeCounts.merge(status, 1, Integer::sum);
requestCount.incrementAndGet();
}

public int getStatusCodeCount(HttpStatus status) {
return statusCodeCounts.getOrDefault(status, 0);
}

public long getRequestCount() {
return requestCount.get();
}
}
5 changes: 5 additions & 0 deletions src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,8 @@ secret:
fcm:
topic:
festival-prefix: example-festival

server:
tomcat:
threads:
max: 15