diff --git a/.github/ci_setup.sql b/.github/ci_setup.sql
new file mode 100644
index 0000000..14be7ca
--- /dev/null
+++ b/.github/ci_setup.sql
@@ -0,0 +1,18 @@
+CREATE USER 'ci_user'@'localhost' IDENTIFIED BY 'ci_user_pass';
+DROP DATABASE IF EXISTS ci; CREATE DATABASE ci;
+
+GRANT LOCK TABLES ON `ci`.* TO `ci_user`@`localhost`;
+GRANT SELECT, INSERT, UPDATE, CREATE, REFERENCES, ALTER ON `ci`.`flyway_schema_history` TO `ci_user`@`localhost`;
+
+create table ci.invite(is_disabled INT, used INT);
+GRANT SELECT, INSERT, UPDATE (`is_disabled`, `used`), CREATE, REFERENCES, ALTER ON `ci`.`invite` TO `ci_user`@`localhost`;
+drop table ci.invite;
+
+GRANT SELECT, INSERT, CREATE, REFERENCES, ALTER ON `ci`.`target_app` TO `ci_user`@`localhost`;
+
+GRANT SELECT, INSERT, CREATE, REFERENCES, ALTER ON `ci`.`invite_discord` TO `ci_user`@`localhost`;
+GRANT SELECT, INSERT, CREATE, REFERENCES, ALTER ON `ci`.`invite_discord_joined_user` TO `ci_user`@`localhost`;
+GRANT SELECT, INSERT, DELETE, CREATE, REFERENCES, ALTER ON `ci`.`invite_discord_state` TO `ci_user`@`localhost`;
+
+GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, REFERENCES, ALTER ON `ci`.`user` TO `ci_user`@`localhost`;
+GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, REFERENCES, ALTER ON `ci`.`user_authority` TO `ci_user`@`localhost`;
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..75c4572
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,54 @@
+name: Test
+
+on:
+ pull_request:
+ push:
+ branches: [ develop ]
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ if: github.actor != 'dependabot[bot]'
+ runs-on: ubuntu-latest
+ concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up database
+ uses: shogo82148/actions-setup-mysql@v1
+ with:
+ mysql-version: "8.0"
+
+ - name: Prepare database
+ run: |
+ mysql -u root < ${{ github.workspace }}/.github/ci_setup.sql
+
+ - name: Set up JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'oracle'
+ java-version: '21'
+
+ - name: Cache Maven packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+
+ - name: Create config file
+ run: |
+ cat << 'EOF' > src/main/resources/application-test.yml
+ ${{ secrets.APPLICATION_TEST_YML }}
+ EOF
+
+ - name: Test
+ run: mvn integration-test
diff --git a/pom.xml b/pom.xml
index dd03fa3..88626df 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,6 +13,10 @@
0.0.1-SNAPSHOT
TechUni Invite System
TechUni Invitation System.
+
+ IT Student Org, Tech.Uni
+ https://techuni.org
+
https://invite.techuni.org
diff --git a/src/main/java/org/techuni/TechUniInviteSystem/controller/LoginController.java b/src/main/java/org/techuni/TechUniInviteSystem/controller/LoginController.java
index 954c231..151aa9f 100644
--- a/src/main/java/org/techuni/TechUniInviteSystem/controller/LoginController.java
+++ b/src/main/java/org/techuni/TechUniInviteSystem/controller/LoginController.java
@@ -41,8 +41,8 @@ public ResponseEntity login(@Validated @NotNull @RequestBo
}
try {
- final var authentication = authenticationManager.authenticate(
- new UsernamePasswordAuthenticationToken(loginRequest.getUser(), loginRequest.getPassword()));
+ final var authentication =
+ authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUser(), loginRequest.getPassword()));
final var token = tokenProvider.generateToken(authentication);
return ResponseEntity.ok().body(new LoginSuccessResponse(token));
diff --git a/src/main/java/org/techuni/TechUniInviteSystem/db/mapper/InviteWithDiscordStateMapper.java b/src/main/java/org/techuni/TechUniInviteSystem/db/mapper/InviteWithDiscordStateMapper.java
index 663d787..83be07d 100644
--- a/src/main/java/org/techuni/TechUniInviteSystem/db/mapper/InviteWithDiscordStateMapper.java
+++ b/src/main/java/org/techuni/TechUniInviteSystem/db/mapper/InviteWithDiscordStateMapper.java
@@ -11,5 +11,5 @@ public interface InviteWithDiscordStateMapper {
Invite getInviteByState(@Param("state") String state);
void cleanState(@Param("nowTime") LocalDateTime nowTime, @Param("stateExpireTime") LocalDateTime stateExpireTime);
-
+
}
diff --git a/src/main/java/org/techuni/TechUniInviteSystem/domain/invite/InviteDto.java b/src/main/java/org/techuni/TechUniInviteSystem/domain/invite/InviteDto.java
index 419f12d..b516f25 100644
--- a/src/main/java/org/techuni/TechUniInviteSystem/domain/invite/InviteDto.java
+++ b/src/main/java/org/techuni/TechUniInviteSystem/domain/invite/InviteDto.java
@@ -93,8 +93,8 @@ public Invite intoDB() {
public > M intoModel(Class modelClass) {
final var _modelClass = targetApplication.getModelClass();
if (!modelClass.equals(_modelClass)) {
- throw ErrorCode.UNEXPECTED_ERROR.exception(
- "Model class is not matched. (Expected: %s, Selected: %s)".formatted(_modelClass.getName(), modelClass.getName()));
+ throw ErrorCode.UNEXPECTED_ERROR
+ .exception("Model class is not matched. (Expected: %s, Selected: %s)".formatted(_modelClass.getName(), modelClass.getName()));
}
return modelClass.cast(intoModel());
diff --git a/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorCode.java b/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorCode.java
index ab68b59..b30e55d 100644
--- a/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorCode.java
+++ b/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorCode.java
@@ -11,8 +11,7 @@ public enum ErrorCode {
/**
* OTHER
*/
- UNEXPECTED_ERROR(ErrorSource.OTHER, 1, HttpStatus.INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_UNEXPECTED_ERROR,
- ErrorMessage.UNEXPECTED_ERROR), //
+ UNEXPECTED_ERROR(ErrorSource.OTHER, 1, HttpStatus.INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_UNEXPECTED_ERROR, ErrorMessage.UNEXPECTED_ERROR), //
/**
* LOGIN
@@ -51,8 +50,7 @@ public enum ErrorCode {
ErrorMessage.DISCORD_LOGIN_DENIED), //
DISCORD_AUTHENTICATED_VALIDATION_ERROR(ErrorSource.INVITATION, 108, HttpStatus.UNAUTHORIZED, null, ErrorMessage.DISCORD_LOGIN_FAILED), //
DISCORD_CREATE_JOIN_DM_ERROR(ErrorSource.INVITATION, 109, HttpStatus.INTERNAL_SERVER_ERROR, null, ErrorMessage.DISCORD_UNEXPECTED_ERROR), //
- DISCORD_CREATE_INVITE_GUILD_ACCESS_ERROR(ErrorSource.INVITATION, 110, HttpStatus.BAD_REQUEST,
- ErrorMessage.INTERNAL_DISCORD_GUILD_ACCESS_ERROR), //
+ DISCORD_CREATE_INVITE_GUILD_ACCESS_ERROR(ErrorSource.INVITATION, 110, HttpStatus.BAD_REQUEST, ErrorMessage.INTERNAL_DISCORD_GUILD_ACCESS_ERROR), //
DISCORD_GUILD_ACCESS_ERROR(ErrorSource.INVITATION, 111, HttpStatus.NOT_FOUND, ErrorMessage.INTERNAL_DISCORD_GUILD_ACCESS_ERROR,
ErrorMessage.INVITATION_INVALID), //
DISCORD_LACK_GUILD_PERMISSION(ErrorSource.INVITATION, 112, HttpStatus.NOT_FOUND, ErrorMessage.INTERNAL_LACK_GUILD_PERMISSION,
@@ -123,8 +121,8 @@ public String getInternalMessage() {
}
public String getInternalMessage(String... args) {
- final var output = Optional.ofNullable(internalMessage).map(ErrorMessage::getMessage).orElse(status.getReasonPhrase())
- .formatted((Object[]) args);
+ final var output =
+ Optional.ofNullable(internalMessage).map(ErrorMessage::getMessage).orElse(status.getReasonPhrase()).formatted((Object[]) args);
return "\"%s\" - ErrorCode.%s".formatted(output, this.name());
}
diff --git a/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorMessage.java b/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorMessage.java
index c1c50d9..ef5ff1d 100644
--- a/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorMessage.java
+++ b/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorMessage.java
@@ -29,10 +29,8 @@ public enum ErrorMessage {
INTERNAL_INVITATION_INVALID("Request Invalid Invitation Code. (Code: %s)"), //
/* DISCORD INVITATION */
- INTERNAL_DISCORD_ALREADY_JOINED(
- "The Discord Invitation target already joined to the guild. (DbId: %s, InvitationCode: %s, Guild: %s, User: %s)"), //
- DISCORD_ALREADY_JOINED(
- "Your account have already joined to the discord server. If you think this is a mistake, please contact to the inviter."), //
+ INTERNAL_DISCORD_ALREADY_JOINED("The Discord Invitation target already joined to the guild. (DbId: %s, InvitationCode: %s, Guild: %s, User: %s)"), //
+ DISCORD_ALREADY_JOINED("Your account have already joined to the discord server. If you think this is a mistake, please contact to the inviter."), //
INTERNAL_DISCORD_LOGIN_FAILED("Discord login failed."), //
DISCORD_LOGIN_FAILED("Login to Your account is failed. If you think this is a mistake, please try again."), //
INTERNAL_DISCORD_LOGIN_UNEXPECTED_ERROR("Unexpected error occurred in Discord login sequence."), //
diff --git a/src/main/java/org/techuni/TechUniInviteSystem/external/discord/template/MessageType.java b/src/main/java/org/techuni/TechUniInviteSystem/external/discord/template/MessageType.java
index 14c78c7..1cde21d 100644
--- a/src/main/java/org/techuni/TechUniInviteSystem/external/discord/template/MessageType.java
+++ b/src/main/java/org/techuni/TechUniInviteSystem/external/discord/template/MessageType.java
@@ -8,8 +8,7 @@
@AllArgsConstructor
public enum MessageType {
- JOIN_SERVER_DM("join-server", JoinServerDMVariable.class),
- ;
+ JOIN_SERVER_DM("join-server", JoinServerDMVariable.class),;
@Getter
private final String templateName;
diff --git a/src/main/java/org/techuni/TechUniInviteSystem/security/MyPasswordEncoder.java b/src/main/java/org/techuni/TechUniInviteSystem/security/MyPasswordEncoder.java
index 6154083..c1fbf20 100644
--- a/src/main/java/org/techuni/TechUniInviteSystem/security/MyPasswordEncoder.java
+++ b/src/main/java/org/techuni/TechUniInviteSystem/security/MyPasswordEncoder.java
@@ -1,10 +1,12 @@
package org.techuni.TechUniInviteSystem.security;
import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
+@Profile("!test")
@Component
public class MyPasswordEncoder {
diff --git a/src/main/java/org/techuni/TechUniInviteSystem/service/DiscordDMService.java b/src/main/java/org/techuni/TechUniInviteSystem/service/DiscordDMService.java
index 744ce90..e331bf5 100644
--- a/src/main/java/org/techuni/TechUniInviteSystem/service/DiscordDMService.java
+++ b/src/main/java/org/techuni/TechUniInviteSystem/service/DiscordDMService.java
@@ -80,8 +80,8 @@ private void sendDM(CreateDMData createDMData) {
if (attachment.forceLoad) { // isForceLoadAttachment
throw new RuntimeException("Failed to load attachment: %s".formatted(attachment.name), e);
} else {
- final var message = "Failed to load attachment. But the DM will sent(because it is not forced attachment). : %s".formatted(
- attachment.name);
+ final var message = "Failed to load attachment. But the DM will sent(because it is not forced attachment). : %s"
+ .formatted(attachment.name);
log.warn(message, e);
return null;
}
diff --git a/src/main/resources/db/mapper_test/TestUserMapper.xml b/src/main/resources/db/mapper_test/TestUserMapper.xml
new file mode 100644
index 0000000..2b0b621
--- /dev/null
+++ b/src/main/resources/db/mapper_test/TestUserMapper.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ INSERT INTO user (uuid, isEnable, name, passHash) VALUES (#{insertUser.uuid}, #{insertUser.isEnable}, #{insertUser.name}, #{insertUser.passHash})
+
+
+
+ INSERT INTO user_authority (id, authority) VALUES
+
+ (#{id}, #{authority})
+
+
+
+
+ DELETE FROM user;
+
+
+
+ DELETE FROM user_authority;
+
+
+
\ No newline at end of file
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/TechUniInviteSystemApplicationTests.java b/src/test/java/org/techuni/TechUniInviteSystem/TechUniInviteSystemApplicationTests.java
index d1cd756..1120d94 100644
--- a/src/test/java/org/techuni/TechUniInviteSystem/TechUniInviteSystemApplicationTests.java
+++ b/src/test/java/org/techuni/TechUniInviteSystem/TechUniInviteSystemApplicationTests.java
@@ -2,8 +2,10 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
+@ActiveProfiles("test")
class TechUniInviteSystemApplicationTests {
@Test
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/controller/LoginControllerIT.java b/src/test/java/org/techuni/TechUniInviteSystem/controller/LoginControllerIT.java
new file mode 100644
index 0000000..56432df
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/controller/LoginControllerIT.java
@@ -0,0 +1,246 @@
+package org.techuni.TechUniInviteSystem.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.techuni.TechUniInviteSystem.controller.request.LoginRequest;
+import org.techuni.TechUniInviteSystem.controller.response.LoginSuccessResponse;
+import org.techuni.TechUniInviteSystem.db.TestUser;
+import org.techuni.TechUniInviteSystem.domain.user.UserModel;
+import org.techuni.TechUniInviteSystem.error.MyHttpException;
+import org.techuni.TechUniInviteSystem.security.JwtTokenProvider;
+import org.techuni.TechUniInviteSystem.type.AbstractIntegrationTest;
+import org.techuni.TechUniInviteSystem.util.StringUtil;
+
+public class LoginControllerIT extends AbstractIntegrationTest {
+
+ @Autowired
+ private JwtTokenProvider tokenProvider;
+
+ @Value("${jwt.issuer}")
+ private String expectedTokenIssuer;
+
+ @Value("${jwt.expiration}")
+ private long jwtExpirationInMs;
+
+ @Override
+ @AfterEach
+ protected void afterEach() {
+ super.afterEach();
+ clearUsers();
+ }
+
+ @ParameterizedTest
+ @MethodSource("validRequestProvider")
+ void 正_ログインをすることができる(final String userName, final String password) throws Exception {
+ registerUser(TestUser.builder(userName) //
+ .rawPassword(password) //
+ .build());
+ final var request = post("/login", null, new LoginRequest(userName, password));
+
+ // execute
+ final var result = mockMvc.perform(request);
+ final var response = result //
+ .andExpect(status().isOk()) //
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //
+ .andReturn() //
+ .getResponse().getContentAsString();
+
+ // verify
+ final var parsedResponse = parseResponse(response, LoginSuccessResponse.class);
+
+ final var _decodedToken = tokenProvider.decode(parsedResponse.getToken());
+ assertThat(_decodedToken.isPresent()).isTrue();
+
+ final var decodedToken = _decodedToken.get();
+ assertThat(decodedToken.getIssuer()).isEqualTo(expectedTokenIssuer);
+ assertThat(decodedToken.getSubject()).isEqualTo(userName);
+ assertThat(decodedToken.getIssuedAt()).isNotNull();
+ if (jwtExpirationInMs > 0) {
+ assertThat(decodedToken.getExpiresAt()).isNotNull();
+ final var expectedExpireAt = new Date(decodedToken.getIssuedAt().getTime() + jwtExpirationInMs);
+ assertThat(decodedToken.getExpiresAt()).isEqualTo(expectedExpireAt);
+ } else {
+ assertThat(decodedToken.getExpiresAt()).isNull();
+ }
+ }
+
+ private static Stream validRequestProvider() {
+ final var userName = "validUserName";
+ final var passwords = new ArrayList<>(List.of("validUser@Password012"));
+
+ // 英字
+ for (char large = 'A'; large <= 'Z'; large++) {
+ final var sample = "0123456789-a" + large;
+ passwords.add(sample);
+
+ }
+ for (char small = 'a'; small <= 'z'; small++) {
+ final var sample = "0123456789-A" + small;
+ passwords.add(sample);
+ }
+
+ // 数字
+ for (var i = 0; i <= 9; i++) {
+ final var sample = "TESTAlphabet-" + i;
+ passwords.add(sample);
+ }
+
+ // 記号
+ for (var c : UserModel.ALLOWED_PASSWORD_SPECIAL_CHARS) {
+ final var sample = "TESTAlphabet0" + c;
+ passwords.add(sample);
+ }
+
+ final var allCondition = passwords.stream() //
+ .map(StringUtil::shuffleString) //
+ .map(password -> Arguments.of(userName, password));
+
+ // 境界
+ final var boundary = Stream.of(Arguments.of("Aa@1-aaaaa", "Aa@1-aaaaa"), // min
+ Arguments.of("Aa@1-aaaaa" + "a".repeat(150 - 10), "Aa@1-aaaaa" + "a".repeat(64 - 10)) // max
+ );
+
+ return Stream.concat(allCondition, boundary);
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidRequestProvider")
+ void 異_要件を満たさない情報でログインできない(final String userName, final String password) throws Exception {
+ final var request = post("/login", null, new LoginRequest(userName, password));
+
+ // execute
+ final var result = mockMvc.perform(request);
+ final var exception = result //
+ .andExpect(status().isUnauthorized()) //
+ .andReturn() //
+ .getResolvedException();
+
+ // verify
+ assertThat(exception) //
+ .isNotNull() //
+ .isInstanceOf(MyHttpException.class) //
+ .matches(e -> ((MyHttpException) e).getStatusCode().equals(HttpStatus.UNAUTHORIZED));
+ }
+
+ private static Stream invalidRequestProvider() {
+ final var validUsername = "validUserName";
+ final var validPassword = "validUser@Password012";
+
+ final var basic = Stream.of( //
+ // null系
+ Arguments.of(null, null), Arguments.of(null, validPassword), Arguments.of(validUsername, null),
+ // 空文字系
+ Arguments.of("", ""), Arguments.of("", validPassword), Arguments.of(validUsername, ""),
+ // 空白文字系
+ Arguments.of(" ", " "), Arguments.of(validUsername, " "), Arguments.of(" ", validPassword), Arguments.of(" ", " "),
+ Arguments.of(validUsername, " "), Arguments.of(" ", validPassword),
+ // ユーザー名系
+ Arguments.of("a", validPassword), // 10文字未満
+ Arguments.of("a".repeat(200), validPassword), // 150文字超過
+ // パスワード系
+ Arguments.of(validUsername, "aA1-"), // 10文字未満
+ Arguments.of(validUsername, "aA1-" + "a".repeat(200)) // 64文字超過
+ );
+
+ // ?のみ系
+ final var onlyCase = Stream.of("abc", // 英小文字のみ
+ "ABC", // 英大文字のみ
+ "123", // 数字のみ
+ "^-@", // 記号のみ
+ "aAb", // 英小文字と英大文字
+ "a1b", // 英小文字と数字
+ "a^b", // 英小文字と記号
+ "A1B", // 英大文字と数字
+ "A^B", // 英大文字と記号
+ "1^1", // 数字と記号
+ "aA1", // 英小文字と英大文字と数字
+ "aA^", // 英小文字と英大文字と記号
+ "a1^", // 英小文字と数字と記号
+ "A1^" // 英大文字と数字と記号
+ ).map(c -> c.repeat(5)) //
+ .flatMap(c -> Stream.of(Arguments.of(validUsername, c), Arguments.of(c, validPassword)));
+
+ return Stream.concat(basic, onlyCase) //
+ .map(a -> Arguments.of(StringUtil.shuffleString((String) a.get()[0]), StringUtil.shuffleString((String) a.get()[1])));
+ }
+
+ @Test
+ void 異_正しくないパスワードでログインできない() throws Exception {
+ registerUser(TestUser.builder("validUserName") //
+ .rawPassword("validUser@Password012") //
+ .build());
+ final var request = post("/login", null, new LoginRequest("validUserName", "incorrectUser@Password123"));
+
+ // execute
+ final var result = mockMvc.perform(request);
+ final var exception = result //
+ .andExpect(status().isUnauthorized()) //
+ .andReturn() //
+ .getResolvedException();
+
+ // verify
+ assertThat(exception) //
+ .isNotNull() //
+ .isInstanceOf(MyHttpException.class) //
+ .matches(e -> ((MyHttpException) e).getStatusCode().equals(HttpStatus.UNAUTHORIZED));
+ }
+
+ @Test
+ void 異_存在しないユーザーでログインできない() throws Exception {
+ clearUsers();
+ final var request = post("/login", null, new LoginRequest("notExistUserName", "notFoundUser@Password123"));
+
+ // execute
+ final var result = mockMvc.perform(request);
+ final var exception = result //
+ .andExpect(status().isUnauthorized()) //
+ .andReturn() //
+ .getResolvedException();
+
+ // verify
+ assertThat(exception) //
+ .isNotNull() //
+ .isInstanceOf(MyHttpException.class) //
+ .matches(e -> ((MyHttpException) e).getStatusCode().equals(HttpStatus.UNAUTHORIZED));
+ }
+
+ @Test
+ void 異_無効なユーザーでログインできない() throws Exception {
+ final String username = "disableUserName", password = "disableUser@Password012";
+ registerUser(TestUser.builder(username) //
+ .rawPassword(password) //
+ .isEnable(false) //
+ .build());
+ final var request = post("/login", null, new LoginRequest(username, password));
+
+ // execute
+ final var result = mockMvc.perform(request);
+ final var exception = result //
+ .andExpect(status().isUnauthorized()) //
+ .andReturn() //
+ .getResolvedException();
+
+ // verify
+ assertThat(exception) //
+ .isNotNull() //
+ .isInstanceOf(MyHttpException.class) //
+ .matches(e -> ((MyHttpException) e).getStatusCode().equals(HttpStatus.UNAUTHORIZED));
+ }
+
+
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/db/TestUser.java b/src/test/java/org/techuni/TechUniInviteSystem/db/TestUser.java
new file mode 100644
index 0000000..516f07d
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/db/TestUser.java
@@ -0,0 +1,58 @@
+package org.techuni.TechUniInviteSystem.db;
+
+import java.util.Random;
+import java.util.Set;
+import java.util.UUID;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.techuni.TechUniInviteSystem.domain.user.UserModel;
+import org.techuni.TechUniInviteSystem.security.UserAuthority;
+import org.techuni.TechUniInviteSystem.util.StringUtil;
+
+@Data
+@Builder(builderMethodName = "")
+@AllArgsConstructor
+@NoArgsConstructor(force = true)
+public class TestUser {
+
+ private Long id;
+
+ @Builder.Default
+ private String uuid = UUID.randomUUID().toString();
+
+ @Builder.Default
+ private boolean isEnable = true;
+
+ private String name;
+
+ @Builder.Default
+ private String rawPassword = generateRandomPass();
+
+ private String passHash;
+
+ @Builder.Default
+ private Set authorities = Set.of();
+
+ public void setPassHash(PasswordEncoder encoder) {
+ this.passHash = encoder.encode(rawPassword);
+ }
+
+ public static TestUserBuilder builder(String name) {
+ return new TestUserBuilder().name(name);
+ }
+
+ private final static Random random = new Random();
+
+ private static String generateRandomPass() {
+ final int min = 32, max = 64;
+ StringBuilder sb = new StringBuilder(RandomStringUtils.secure().nextAlphanumeric(min, max + 1));
+ // 記号
+ sb.setCharAt(random.nextInt(sb.length()),
+ UserModel.ALLOWED_PASSWORD_SPECIAL_CHARS.get(random.nextInt(UserModel.ALLOWED_PASSWORD_SPECIAL_CHARS.size())));
+ return StringUtil.shuffleString(sb.toString());
+ }
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/db/TestUserRepository.java b/src/test/java/org/techuni/TechUniInviteSystem/db/TestUserRepository.java
new file mode 100644
index 0000000..e45a460
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/db/TestUserRepository.java
@@ -0,0 +1,17 @@
+package org.techuni.TechUniInviteSystem.db;
+
+import java.util.List;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface TestUserRepository {
+
+ long insertUser(@Param("insertUser") TestUser insertUser);
+
+ void insertUserAuthority(@Param("id") long id, @Param("authorities") List authorities);
+
+ void resetUser();
+
+ void resetUserAuthority();
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/sample/InviteSample.java b/src/test/java/org/techuni/TechUniInviteSystem/sample/InviteSample.java
new file mode 100644
index 0000000..34b513d
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/sample/InviteSample.java
@@ -0,0 +1,38 @@
+package org.techuni.TechUniInviteSystem.sample;
+
+import java.time.ZonedDateTime;
+import java.util.UUID;
+import lombok.Builder;
+import lombok.Data;
+import org.techuni.TechUniInviteSystem.domain.invite.InviteDto;
+import org.techuni.TechUniInviteSystem.domain.invite.TargetApplication;
+import org.techuni.TechUniInviteSystem.domain.invite.models.additional.AbstractInviteAdditionalData;
+
+@Data
+@Builder
+public class InviteSample {
+
+ @Builder.Default
+ int dbId = -1;
+ @Builder.Default
+ UUID invitationCode = UUID.randomUUID();
+ @Builder.Default
+ String searchId = null;
+ @Builder.Default
+ boolean isDisabled = false;
+ @Builder.Default
+ int used = 0;
+ @Builder.Default
+ int maxUsed = 1;
+ @Builder.Default
+ TargetApplication targetApplication = TargetApplication.DISCORD;
+ @Builder.Default
+ ZonedDateTime expiresAt = null;
+ @Builder.Default
+ AbstractInviteAdditionalData additionalData = null;
+
+ public InviteDto intoDto() {
+ return new InviteDto(dbId, invitationCode, searchId, isDisabled, used, maxUsed, targetApplication, expiresAt, additionalData);
+ }
+
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/MemberDataSample.java b/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/MemberDataSample.java
new file mode 100644
index 0000000..97b73c4
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/MemberDataSample.java
@@ -0,0 +1,29 @@
+package org.techuni.TechUniInviteSystem.sample.discord;
+
+import discord4j.discordjson.json.MemberData;
+import discord4j.discordjson.json.UserData;
+import discord4j.rest.util.PermissionSet;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class MemberDataSample {
+
+ @Builder.Default
+ UserData user = UserDataSample.builder().build().intoData();
+
+ @Builder.Default
+ String permissions = PermissionSet.all().toString();
+
+ @Builder.Default
+ boolean deaf = false;
+
+ @Builder.Default
+ boolean mute = false;
+
+ public MemberData intoData() {
+ return MemberData.builder().user(user).permissions(permissions).deaf(deaf).mute(mute).build();
+ }
+
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/UserDataSample.java b/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/UserDataSample.java
new file mode 100644
index 0000000..2cb657c
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/UserDataSample.java
@@ -0,0 +1,29 @@
+package org.techuni.TechUniInviteSystem.sample.discord;
+
+import discord4j.discordjson.Id;
+import discord4j.discordjson.json.UserData;
+import java.util.Optional;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class UserDataSample {
+
+ @Builder.Default
+ Id id = Id.of(1234567890123456789L);
+
+ @Builder.Default
+ Optional globalName = Optional.of("TechUniSampleUser");
+
+ @Builder.Default
+ String username = "TechUniSampleUser";
+
+ @Builder.Default
+ String discriminator = "1234";
+
+ public UserData intoData() {
+ return UserData.builder().id(id).globalName(globalName).username(username).discriminator(discriminator).build();
+ }
+
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/security/TestPasswordEncoder.java b/src/test/java/org/techuni/TechUniInviteSystem/security/TestPasswordEncoder.java
new file mode 100644
index 0000000..1483709
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/security/TestPasswordEncoder.java
@@ -0,0 +1,20 @@
+package org.techuni.TechUniInviteSystem.security;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+/**
+ * Test用 PasswordEncoder rounds数を下げてテスト時間を短くする
+ */
+@Profile("test")
+@Component
+public class TestPasswordEncoder {
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion.$2B, 4);
+ }
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/service/DiscordInviteServiceTest.java b/src/test/java/org/techuni/TechUniInviteSystem/service/DiscordInviteServiceTest.java
new file mode 100644
index 0000000..ce82141
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/service/DiscordInviteServiceTest.java
@@ -0,0 +1,148 @@
+package org.techuni.TechUniInviteSystem.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.when;
+
+import discord4j.rest.util.PermissionSet;
+import java.util.Optional;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.techuni.TechUniInviteSystem.controller.response.invite.DiscordJoinSuccessResponse;
+import org.techuni.TechUniInviteSystem.db.repository.DiscordInviteRepository;
+import org.techuni.TechUniInviteSystem.domain.invite.models.DiscordInviteModel;
+import org.techuni.TechUniInviteSystem.domain.invite.models.additional.DiscordInviteAdditionalData;
+import org.techuni.TechUniInviteSystem.domain.invite.models.additional.DiscordUsingInviteAddtionalData;
+import org.techuni.TechUniInviteSystem.external.discord.DiscordAPIFactory;
+import org.techuni.TechUniInviteSystem.external.discord.template.variables.JoinServerDMVariable;
+import org.techuni.TechUniInviteSystem.sample.InviteSample;
+import org.techuni.TechUniInviteSystem.sample.discord.MemberDataSample;
+import org.techuni.TechUniInviteSystem.service.invite.DiscordInviteService;
+import org.techuni.TechUniInviteSystem.type.AbstractUnitTest;
+
+public class DiscordInviteServiceTest extends AbstractUnitTest {
+
+ @Mock
+ DiscordInviteRepository discordInviteRepository;
+
+ @Mock
+ DiscordAPIFactory discordAPIFactory;
+
+ @Mock
+ DiscordAPIService apiService;
+
+ @Mock
+ DiscordDMService discordDMService;
+
+ @InjectMocks
+ DiscordInviteService discordInviteService;
+
+ String clientId;
+ String authenticatedEndpoint;
+
+ @BeforeAll
+ public void beforeAll() {
+ clientId = RandomStringUtils.insecure().nextNumeric(19); // DiscordBotのClientIDは19桁
+ authenticatedEndpoint = "http://localhost:8080/authenticated";
+
+ // mock
+ try {
+ setField("clientId", clientId);
+ setField("authenticatedEndpoint", authenticatedEndpoint);
+ setField("zoneId", zoneId);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ void 正_正しい内容で招待を作成できる() {
+ /* input setup */
+ final var additionalData = new DiscordInviteAdditionalData("1234567890123456789", "sample user");
+ final var registeredInviteDto = InviteSample.builder().additionalData(additionalData).dbId(1).build().intoDto();
+
+ final var guildId = Long.parseLong(additionalData.getGuildID());
+ final var inviteDBid = registeredInviteDto.getDbId();
+
+ /* mock */
+ when(apiService.checkBotHasPermission(eq(guildId), any())).thenReturn(true);
+ doNothing().when(discordInviteRepository).createInvite(inviteDBid, guildId, additionalData.getNickname());
+
+ /* execute */
+ assertThat(discordInviteService.createInvite(registeredInviteDto)) //
+ .isEqualTo(registeredInviteDto);
+ }
+
+ @Test
+ void 正_招待を受諾できる() throws NoSuchFieldException, IllegalAccessException {
+ /* input setup */
+ final var additionalData = new DiscordInviteAdditionalData("1234567890123456789", "sample user");
+ final var registeredInviteDto = InviteSample.builder().additionalData(additionalData).dbId(1).build().intoDto();
+
+ final var guildId = Long.parseLong(additionalData.getGuildID());
+ final var inviteDBid = registeredInviteDto.getDbId();
+
+ final int stateLength = (int) getFieldValue("STATE_LENGTH");
+
+ /* mock */
+ when(apiService.checkBotHasPermission(eq(guildId), any())).thenReturn(true);
+ doNothing().when(discordInviteRepository).addInviteState(eq(inviteDBid), any());
+
+ /* execute */
+ assertThat(discordInviteService.acceptInvite(registeredInviteDto)) //
+ .isNotNull() //
+ .matches(r -> r.getClientID().equals(clientId)) //
+ .matches(r -> r.getRedirectURI().equals(authenticatedEndpoint)) //
+ .matches(r -> StringUtils.isAlphanumeric(r.getState()) && r.getState().length() == stateLength);
+ }
+
+ @Test
+ void 正_招待できる() {
+ /* input setup */
+ final var useInviteAdditionalData = new DiscordUsingInviteAddtionalData("sampleAPIAuthenticatedCode");
+
+ final var additionalData = new DiscordInviteAdditionalData("1234567890123456789", "sample user");
+ final var registeredInviteDto = InviteSample.builder().additionalData(additionalData).dbId(1).build().intoDto();
+ final var model = registeredInviteDto.intoModel(DiscordInviteModel.class);
+
+ final var createdMember = MemberDataSample.builder().permissions(PermissionSet.none().toString()).build().intoData();
+
+ final var guildId = Long.parseLong(additionalData.getGuildID());
+ final var inviteDBid = registeredInviteDto.getDbId();
+ final var joinedUserId = createdMember.user().id().asLong();
+
+ /* mock */
+ when(apiService.checkBotHasPermission(eq(guildId), any())).thenReturn(true);
+ when(discordAPIFactory.createAPI(any())).thenReturn(null);
+ when(apiService.joinGuild(any(), eq(model))).thenReturn(createdMember);
+ doNothing().when(discordInviteRepository).addJoinedUser(eq(inviteDBid), eq(joinedUserId));
+ doNothing().when(discordDMService).scheduleDM(eq(joinedUserId), eq(new JoinServerDMVariable( //
+ joinedUserId, //
+ Optional.ofNullable(additionalData.getNickname()).orElse(createdMember.user().username()) //
+ )));
+
+ /* execute */
+ final var expected = new DiscordJoinSuccessResponse(guildId);
+ assertThat(discordInviteService.useInvite(registeredInviteDto, useInviteAdditionalData)) //
+ .isEqualTo(expected);
+ }
+
+ private Object getFieldValue(String fieldName) throws NoSuchFieldException, IllegalAccessException {
+ final var field = DiscordInviteService.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(discordInviteService);
+ }
+
+ private void setField(String fieldName, Object value) throws NoSuchFieldException, IllegalAccessException {
+ final var field = DiscordInviteService.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(discordInviteService, value);
+ }
+
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/service/InviteServiceTest.java b/src/test/java/org/techuni/TechUniInviteSystem/service/InviteServiceTest.java
new file mode 100644
index 0000000..05b87de
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/service/InviteServiceTest.java
@@ -0,0 +1,159 @@
+package org.techuni.TechUniInviteSystem.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.when;
+
+import java.time.ZonedDateTime;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.techuni.TechUniInviteSystem.controller.response.invite.DiscordAuthRequestResponse;
+import org.techuni.TechUniInviteSystem.controller.response.invite.DiscordJoinSuccessResponse;
+import org.techuni.TechUniInviteSystem.db.repository.InviteRepository;
+import org.techuni.TechUniInviteSystem.domain.invite.InviteDto;
+import org.techuni.TechUniInviteSystem.domain.invite.models.additional.DiscordUsingInviteAddtionalData;
+import org.techuni.TechUniInviteSystem.error.ErrorCode;
+import org.techuni.TechUniInviteSystem.error.MyHttpException;
+import org.techuni.TechUniInviteSystem.sample.InviteSample;
+import org.techuni.TechUniInviteSystem.service.invite.DiscordInviteService;
+import org.techuni.TechUniInviteSystem.type.AbstractUnitTest;
+
+public class InviteServiceTest extends AbstractUnitTest {
+
+ @Mock
+ InviteRepository inviteRepository;
+
+ @Mock
+ DiscordInviteService discordInviteService;
+
+ @InjectMocks
+ InviteService inviteService;
+
+ @BeforeAll
+ public void beforeAll() {
+ // mock
+ try {
+ setField("zoneId", zoneId);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ void 正_正しい内容で招待を作成できる() {
+ /* input setup */
+ final var inviteDto = InviteSample.builder().build().intoDto();
+ final var resultDto = InviteSample.builder().dbId(1).build().intoDto();
+
+ /* mock */
+ when(inviteRepository.createInvite(inviteDto)).thenReturn(resultDto);
+ when(discordInviteService.createInvite(resultDto)).thenReturn(resultDto);
+
+ /* execute */
+ inviteService.createInvite(inviteDto);
+ }
+
+ @Test
+ void 正_有効な招待を受諾できる() {
+ /* input setup */
+ final var inviteDto = InviteSample.builder().dbId(1).build().intoDto();
+ final var response = new DiscordAuthRequestResponse("1234567890123456789", "http://localhost:8080", "state");
+
+ /* mock */
+ when(discordInviteService.acceptInvite(inviteDto)).thenReturn(response);
+
+ /* execute */
+ assertThat(inviteService.acceptInvite(inviteDto)) //
+ .isEqualTo(response.intoView());
+ }
+
+ @Test
+ void 正_招待できる() {
+ /* input setup */
+ final var resultDto = InviteSample.builder().dbId(1).build().intoDto();
+ final var usingAdditionalData = new DiscordUsingInviteAddtionalData("sampleAPIAuthenticatedCode");
+ final var response = new DiscordJoinSuccessResponse(1234567890123456789L);
+
+ /* mock */
+ doNothing().when(inviteRepository).useInvite(resultDto.getDbId());
+ when(discordInviteService.useInvite(resultDto, usingAdditionalData)).thenReturn(response);
+
+ /* execute */
+ assertThat(inviteService.useInvite(resultDto, usingAdditionalData)) //
+ .isEqualTo(response);
+ }
+
+ @Test
+ void 異_すでに登録されている招待を作成できない() {
+ /* input setup */
+ final var inviteDto = InviteSample.builder().dbId(1).build().intoDto();
+
+ // 期待される例外が発生することを検証
+ assertThatThrownBy(() -> {
+ inviteService.createInvite(inviteDto);
+ }).isInstanceOf(MyHttpException.class) //
+ .matches(e -> ((MyHttpException) e).getErrorCode().equals(ErrorCode.INVITATION_CREATE_REGISTERED_INVITE));
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidCreateInviteAcceptProvider")
+ void 異_無効な招待を作成できない(final InviteDto inviteDto) {
+
+ // 期待される例外が発生することを検証
+ assertThatThrownBy(() -> {
+ inviteService.createInvite(inviteDto);
+ }).isInstanceOf(MyHttpException.class) //
+ .matches(e -> ((MyHttpException) e).getErrorCode().equals(ErrorCode.INVITATION_CREATE_INVALID_INVITE));
+ }
+
+ private Stream invalidCreateInviteAcceptProvider() {
+ final var expiredDate = ZonedDateTime.now(zoneId).minusDays(1);
+
+ return Stream.of( //
+ InviteSample.builder().isDisabled(true).build().intoDto(), // disable
+ InviteSample.builder().used(0).maxUsed(0).build().intoDto(), // used
+ InviteSample.builder().expiresAt(expiredDate).build().intoDto(), // expired
+ InviteSample.builder().isDisabled(true).used(0).maxUsed(0).build().intoDto(), // disable & used
+ InviteSample.builder().used(0).maxUsed(0).expiresAt(expiredDate).build().intoDto(), // used & expired
+ InviteSample.builder().isDisabled(true).expiresAt(expiredDate).build().intoDto(), // disable & expired
+ InviteSample.builder().isDisabled(true) //
+ .used(0).maxUsed(0).expiresAt(expiredDate).build().intoDto() // disable & used & expired
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidInviteAcceptProvider")
+ void 異_無効な招待を受諾できない(final InviteDto inviteDto) {
+ assertThatThrownBy(() -> {
+ inviteService.acceptInvite(inviteDto);
+ }).isInstanceOf(MyHttpException.class) //
+ .matches(e -> ((MyHttpException) e).getErrorCode().equals(ErrorCode.INVITATION_INVALID));
+ }
+
+ private Stream invalidInviteAcceptProvider() {
+ final var expiredDate = ZonedDateTime.now(zoneId).minusDays(1);
+
+ return Stream.of( //
+ InviteSample.builder().dbId(1).isDisabled(true).build().intoDto(), // disable
+ InviteSample.builder().dbId(2).used(2).maxUsed(2).build().intoDto(), // used
+ InviteSample.builder().dbId(3).expiresAt(expiredDate).build().intoDto(), // expired
+ InviteSample.builder().dbId(4).isDisabled(true).used(2).maxUsed(2).build().intoDto(), // disable & used
+ InviteSample.builder().dbId(5).used(2).maxUsed(2).expiresAt(expiredDate).build().intoDto(), // used & expired
+ InviteSample.builder().dbId(6).isDisabled(true).expiresAt(expiredDate).build().intoDto(), // disable & expired
+ InviteSample.builder().dbId(7).isDisabled(true) //
+ .used(2).maxUsed(2).expiresAt(expiredDate).build().intoDto() // disable & used & expired
+ );
+ }
+
+ private void setField(String fieldName, Object value) throws NoSuchFieldException, IllegalAccessException {
+ final var field = InviteService.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(inviteService, value);
+ }
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/service/UserServiceIT.java b/src/test/java/org/techuni/TechUniInviteSystem/service/UserServiceIT.java
new file mode 100644
index 0000000..6277df6
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/service/UserServiceIT.java
@@ -0,0 +1,49 @@
+package org.techuni.TechUniInviteSystem.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.techuni.TechUniInviteSystem.db.TestUser;
+import org.techuni.TechUniInviteSystem.security.UserAuthority;
+import org.techuni.TechUniInviteSystem.type.AbstractIntegrationTest;
+
+public class UserServiceIT extends AbstractIntegrationTest {
+
+ @Autowired
+ private UserService userService;
+
+ @Test
+ void 正_ユーザーを取得できる() throws Exception {
+ final var expected = registerUser(TestUser.builder("sample-user-123") //
+ .authorities(Set.of(UserAuthority.INVITE_WRITE)) //
+ .build());
+
+ // execute
+ final var result = userService.getUserByName(expected.getName(), false);
+ assertThat(result.isPresent()).isTrue();
+
+ final var user = result.get().intoModel();
+ assertThat(user).isEqualTo(expected);
+ }
+
+ @Test
+ void 正_無効なユーザーをフィルタできる() throws Exception {
+ final var expected = registerUser(TestUser.builder("sample-user-disable") //
+ .isEnable(false) //
+ .build());
+ // execute
+ final var result = userService.getUserByName(expected.getName(), true);
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ @Test
+ void 異_存在しないユーザー名で取得できない() throws Exception {
+ clearUsers();
+
+ // execute
+ assertThat(userService.getUserByName("notfound-user-name", true).isEmpty()).isTrue();
+ assertThat(userService.getUserByName("notfound-user-name", false).isEmpty()).isTrue();
+ }
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractIntegrationTest.java b/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractIntegrationTest.java
new file mode 100644
index 0000000..e381d9b
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractIntegrationTest.java
@@ -0,0 +1,156 @@
+package org.techuni.TechUniInviteSystem.type;
+
+import java.util.Optional;
+import lombok.NoArgsConstructor;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionStatus;
+import org.springframework.transaction.support.DefaultTransactionDefinition;
+import org.techuni.TechUniInviteSystem.db.TestUser;
+import org.techuni.TechUniInviteSystem.db.TestUserRepository;
+import org.techuni.TechUniInviteSystem.db.entity.User;
+import org.techuni.TechUniInviteSystem.db.repository.UserRepository;
+import org.techuni.TechUniInviteSystem.domain.user.UserDto;
+import org.techuni.TechUniInviteSystem.domain.user.UserModel;
+import org.techuni.TechUniInviteSystem.security.TestPasswordEncoder;
+import org.techuni.TechUniInviteSystem.security.UserAuthority;
+import org.techuni.TechUniInviteSystem.util.AuthTokenUtil;
+import org.techuni.TechUniInviteSystem.util.GsonUtil;
+
+@SpringBootTest
+@ActiveProfiles("test")
+@AutoConfigureMockMvc
+@ExtendWith(SpringExtension.class)
+@TestInstance(Lifecycle.PER_CLASS)
+@TestMethodOrder(OrderAnnotation.class)
+@IntegrationTest
+@NoArgsConstructor
+public abstract class AbstractIntegrationTest {
+
+ @Autowired
+ private PlatformTransactionManager transactionManager;
+ @Autowired
+ private GsonUtil gsonUtil;
+ @Autowired
+ private AuthTokenUtil authTokenUtil;
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private TestPasswordEncoder passwordEncoder;
+ @Autowired
+ private TestUserRepository testUserRepository;
+
+ @Autowired
+ protected MockMvc mockMvc;
+
+ private TransactionStatus allTransactionsStatus;
+ private TransactionStatus eachTransactionStatus;
+
+
+ @BeforeAll
+ protected void beforeAll() {
+ Assertions.setMaxStackTraceElementsDisplayed(100);
+ allTransactionsStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());
+ }
+
+ @BeforeEach
+ protected void beforeEach() {
+ final var def = new DefaultTransactionDefinition();
+ def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_NESTED);
+ eachTransactionStatus = transactionManager.getTransaction(def);
+ }
+
+ @AfterEach
+ protected void afterEach() {
+ transactionManager.rollback(eachTransactionStatus);
+ }
+
+ @AfterAll
+ protected void afterAll() {
+ transactionManager.rollback(allTransactionsStatus);
+ }
+
+ protected UserModel registerUser(TestUser testUser) {
+ testUser.setPassHash(passwordEncoder.passwordEncoder());
+
+ // execute
+ final var generatedId = testUserRepository.insertUser(testUser);
+ if (!testUser.getAuthorities().isEmpty()) {
+ testUserRepository.insertUserAuthority(testUser.getId(), testUser.getAuthorities().stream().map(UserAuthority::getAuthority).toList());
+ }
+
+ return new UserDto(new User(testUser.getId(), testUser.getUuid(), testUser.isEnable(), testUser.getName(), testUser.getPassHash(),
+ testUser.getAuthorities())).intoModel();
+ }
+
+ protected void clearUsers() {
+ testUserRepository.resetUserAuthority();
+ testUserRepository.resetUser();
+ }
+
+ protected MockHttpServletRequestBuilder get(final String url, final UserModel user, final Object content) {
+ final var headers = new HttpHeaders();
+ Optional.ofNullable(user).ifPresent(u -> //
+ headers.setBearerAuth(authTokenUtil.generateToken(u)));
+
+ return MockMvcRequestBuilders.get(url) //
+ .contentType("application/json") //
+ .content(gsonUtil.getGson().toJson(content)) //
+ .headers(headers).servletPath(url);
+ }
+
+ protected MockHttpServletRequestBuilder post(final String url, final UserModel user, final Object content) {
+ final var headers = new HttpHeaders();
+ Optional.ofNullable(user).ifPresent(u -> //
+ headers.setBearerAuth(authTokenUtil.generateToken(u)));
+
+ return MockMvcRequestBuilders.post(url) //
+ .contentType("application/json") //
+ .content(gsonUtil.getGson().toJson(content)) //
+ .headers(headers).servletPath(url);
+ }
+
+ protected MockHttpServletRequestBuilder put(final String url, final UserModel user, final Object content) {
+ final var headers = new HttpHeaders();
+ Optional.ofNullable(user).ifPresent(u -> //
+ headers.setBearerAuth(authTokenUtil.generateToken(u)));
+
+ return MockMvcRequestBuilders.put(url) //
+ .contentType("application/json") //
+ .content(gsonUtil.getGson().toJson(content)) //
+ .headers(headers).servletPath(url);
+ }
+
+ protected MockHttpServletRequestBuilder delete(final String url, final UserModel user, final Object content) {
+ final var headers = new HttpHeaders();
+ Optional.ofNullable(user).ifPresent(u -> //
+ headers.setBearerAuth(authTokenUtil.generateToken(u)));
+
+ return MockMvcRequestBuilders.delete(url) //
+ .contentType("application/json") //
+ .content(gsonUtil.getGson().toJson(content)) //
+ .headers(headers).servletPath(url);
+ }
+
+ protected T parseResponse(String responseJson, Class clazz) {
+ return gsonUtil.getGson().fromJson(responseJson, clazz);
+ }
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractUnitTest.java b/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractUnitTest.java
new file mode 100644
index 0000000..6d6457e
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractUnitTest.java
@@ -0,0 +1,42 @@
+package org.techuni.TechUniInviteSystem.type;
+
+import java.time.ZoneId;
+import lombok.NoArgsConstructor;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@ActiveProfiles("test")
+@ExtendWith(SpringExtension.class)
+@TestInstance(Lifecycle.PER_CLASS)
+@TestMethodOrder(OrderAnnotation.class)
+@UnitTest
+@NoArgsConstructor
+public abstract class AbstractUnitTest {
+
+ protected ZoneId zoneId = ZoneId.of("Asia/Tokyo");
+
+ @BeforeAll
+ protected void beforeAll() {
+ Assertions.setMaxStackTraceElementsDisplayed(100);
+ }
+
+ @BeforeEach
+ protected void beforeEach() {}
+
+ @AfterEach
+ protected void afterEach() {}
+
+ @AfterAll
+ protected void afterAll() {}
+
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/type/IntegrationTest.java b/src/test/java/org/techuni/TechUniInviteSystem/type/IntegrationTest.java
new file mode 100644
index 0000000..134be79
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/type/IntegrationTest.java
@@ -0,0 +1,16 @@
+package org.techuni.TechUniInviteSystem.type;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.junit.jupiter.api.Tag;
+
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@Tag("integrationTest")
+@Inherited
+public @interface IntegrationTest {
+
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/type/UnitTest.java b/src/test/java/org/techuni/TechUniInviteSystem/type/UnitTest.java
new file mode 100644
index 0000000..36e386a
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/type/UnitTest.java
@@ -0,0 +1,16 @@
+package org.techuni.TechUniInviteSystem.type;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.junit.jupiter.api.Tag;
+
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@Tag("unitTest")
+@Inherited
+public @interface UnitTest {
+
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/util/AuthTokenUtil.java b/src/test/java/org/techuni/TechUniInviteSystem/util/AuthTokenUtil.java
new file mode 100644
index 0000000..f791d35
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/util/AuthTokenUtil.java
@@ -0,0 +1,21 @@
+package org.techuni.TechUniInviteSystem.util;
+
+import lombok.AllArgsConstructor;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.stereotype.Component;
+import org.techuni.TechUniInviteSystem.domain.user.UserModel;
+import org.techuni.TechUniInviteSystem.security.JwtTokenProvider;
+
+@Component
+@AllArgsConstructor
+public class AuthTokenUtil {
+
+ private final JwtTokenProvider tokenProvider;
+
+ public String generateToken(UserModel model) {
+ final var userDetails = model.intoSpringUser();
+ final var authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
+
+ return tokenProvider.generateToken(authentication);
+ }
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/util/GsonUtil.java b/src/test/java/org/techuni/TechUniInviteSystem/util/GsonUtil.java
new file mode 100644
index 0000000..3873f03
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/util/GsonUtil.java
@@ -0,0 +1,33 @@
+package org.techuni.TechUniInviteSystem.util;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonSerializer;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import lombok.Getter;
+import org.springframework.stereotype.Component;
+
+@Component
+@Getter
+public class GsonUtil {
+
+ private final static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+ private final Gson gson;
+
+ public GsonUtil() {
+ gson = (new GsonBuilder()) //
+ .registerTypeAdapter(OffsetDateTime.class, offsetDateTimeJsonDeserializer()) //
+ .registerTypeAdapter(OffsetDateTime.class, offsetDateTimeJsonSerializer()) //
+ .create();
+ }
+
+ private static JsonDeserializer offsetDateTimeJsonDeserializer() {
+ return (json, typeOfT, context) -> OffsetDateTime.parse(json.getAsString(), dateTimeFormatter);
+ }
+
+ private static JsonSerializer offsetDateTimeJsonSerializer() {
+ return (src, typeOfSrc, context) -> context.serialize(src.format(dateTimeFormatter));
+ }
+}
diff --git a/src/test/java/org/techuni/TechUniInviteSystem/util/StringUtil.java b/src/test/java/org/techuni/TechUniInviteSystem/util/StringUtil.java
new file mode 100644
index 0000000..ee469bb
--- /dev/null
+++ b/src/test/java/org/techuni/TechUniInviteSystem/util/StringUtil.java
@@ -0,0 +1,19 @@
+package org.techuni.TechUniInviteSystem.util;
+
+import static java.util.Objects.isNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class StringUtil {
+
+ public static String shuffleString(String string) {
+ if (isNull(string)) {
+ return null;
+ }
+ List letters = new ArrayList<>(List.of(string.split("")));
+ Collections.shuffle(letters);
+ return String.join("", letters);
+ }
+}