Skip to content

Commit

Permalink
FCM 토큰 발급 및 저장 (#153)
Browse files Browse the repository at this point in the history
* chore: fcm 설정을 위한 configuration 등록

* feat: FCM token 발급 및 저장 기능 구현

* style: 불필요한 로직 제거

* test: FCM 토큰 저장 테스트코드 작성

* test: FCM 토큰 저장 RESTDocs 테스트코드 작성

* test: FCM 토큰 저장 인수 테스트코드 작성
  • Loading branch information
23Yong authored Nov 9, 2023
1 parent de66415 commit 23b8fea
Show file tree
Hide file tree
Showing 18 changed files with 382 additions and 16 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ replay_pid*
application-db.yml
application-oauth.yml
application-secret.yml

# FCM
firebase-admin-sdk.json
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ dependencies {
// REST Docs
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

// FCM
implementation 'com.google.firebase:firebase-admin:9.2.0'
}

/** QueryDSL start **/
Expand Down Expand Up @@ -114,7 +117,7 @@ tasks.register('copySecret', Copy) {
description = 'Copy submodules to project'

from('./src/main/resources/secret') {
include('*.yml')
include('*')
}
into('src/main/resources')
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package kr.codesquad.secondhand.application.firebase;

import com.google.auth.oauth2.AccessToken;
import com.google.auth.oauth2.GoogleCredentials;
import kr.codesquad.secondhand.application.redis.RedisService;
import kr.codesquad.secondhand.exception.ErrorCode;
import kr.codesquad.secondhand.exception.InternalServerException;
import kr.codesquad.secondhand.infrastructure.properties.FcmProperties;
import kr.codesquad.secondhand.presentation.dto.fcm.FcmTokenIssueResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.FileInputStream;
import java.io.IOException;

@RequiredArgsConstructor
@Service
public class FcmTokenService {

private static final String FCM_TOKEN_PREFIX = "fcm_token:";

private final FcmProperties fcmProperties;
private final RedisService redisService;

public void updateToken(String token, Long memberId) {
redisService.set(FCM_TOKEN_PREFIX + memberId, token, fcmProperties.getExpirationMillis());
}

public FcmTokenIssueResponse issueToken() {
try (FileInputStream serviceAccount = new FileInputStream(fcmProperties.getPrivateKeyPath())) {
GoogleCredentials credentials = GoogleCredentials.fromStream(serviceAccount)
.createScoped(fcmProperties.getScopes());

AccessToken accessToken = credentials.refreshAccessToken();

return new FcmTokenIssueResponse(accessToken.getTokenValue());
} catch (IOException e) {
throw new InternalServerException(ErrorCode.FIREBASE_CONFIG_ERROR, "FCM 토큰 발급에 실패했습니다.");
}
}
}
51 changes: 51 additions & 0 deletions src/main/java/kr/codesquad/secondhand/config/FcmConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package kr.codesquad.secondhand.config;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging;
import kr.codesquad.secondhand.exception.ErrorCode;
import kr.codesquad.secondhand.exception.InternalServerException;
import kr.codesquad.secondhand.infrastructure.properties.FcmProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.List;

@RequiredArgsConstructor
@Profile("!test")
@Configuration
public class FcmConfig {

private final FcmProperties fcmProperties;

@Bean
public FirebaseMessaging firebaseMessaging() {
List<FirebaseApp> apps = FirebaseApp.getApps();

try (FileInputStream refreshToken = new FileInputStream(fcmProperties.getPrivateKeyPath())) {
return apps.stream()
.filter(app -> app.getName().equals(FirebaseApp.DEFAULT_APP_NAME))
.map(FirebaseMessaging::getInstance)
.findFirst()
.orElseGet(() -> createFirebaseMessaging(refreshToken));
} catch (IOException e) {
throw new InternalServerException(ErrorCode.FIREBASE_CONFIG_ERROR, "Firebase 설정 파일을 읽어올 수 없습니다.");
}
}

private FirebaseMessaging createFirebaseMessaging(FileInputStream refreshToken) {
try {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(refreshToken))
.build();

return FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options));
} catch (IOException e) {
throw new InternalServerException(ErrorCode.FIREBASE_CONFIG_ERROR, "Firebase 설정 파일을 읽어올 수 없습니다.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ public enum ErrorCode {
// COMMON
INVALID_PARAMETER("유효한 파라미터값이 아닙니다."),
INVALID_REQUEST("유효한 요청이 아닙니다."),
NOT_FOUND("페이지를 찾을 수 없습니다.");
NOT_FOUND("페이지를 찾을 수 없습니다."),

// FIREBASE
FIREBASE_CONFIG_ERROR("Firebase 설정 파일을 읽어올 수 없습니다."),
FCM_TOKEN_NOT_FOUND("FCM 토큰을 찾을 수 없습니다.");

private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package kr.codesquad.secondhand.infrastructure.properties;

import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;

@Getter
@ConfigurationProperties("fcm")
public class FcmProperties {

private static final String MESSAGING_SCOPE = "https://www.googleapis.com/auth/firebase.messaging";

private final String privateKeyPath;
private final long expirationMillis;
private final String[] scopes;

@ConstructorBinding
public FcmProperties(String privateKeyPath, long expirationMillis) {
this.privateKeyPath = privateKeyPath;
this.expirationMillis = expirationMillis;
this.scopes = new String[]{MESSAGING_SCOPE};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package kr.codesquad.secondhand.presentation;

import kr.codesquad.secondhand.application.firebase.FcmTokenService;
import kr.codesquad.secondhand.presentation.dto.fcm.FcmTokenIssueResponse;
import kr.codesquad.secondhand.presentation.dto.fcm.FcmTokenUpdateRequest;
import kr.codesquad.secondhand.presentation.support.Auth;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;

@RequiredArgsConstructor
@RequestMapping("/api/fcm-token")
@RestController
public class FcmController {

private final FcmTokenService fcmTokenService;

@PatchMapping
public void updateToken(@Valid @RequestBody FcmTokenUpdateRequest request,
@Auth Long memberId) {
fcmTokenService.updateToken(request.getToken(), memberId);
}

@ResponseStatus(HttpStatus.CREATED)
@PostMapping
public FcmTokenIssueResponse issueToken() {
return fcmTokenService.issueToken();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.codesquad.secondhand.presentation.dto.fcm;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class FcmTokenIssueResponse {

private final String token;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package kr.codesquad.secondhand.presentation.dto.fcm;

import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotEmpty;

@Getter
@NoArgsConstructor
public class FcmTokenUpdateRequest {

@NotEmpty(message = "토큰은 필수 입력 값입니다.")
private String token;
}
2 changes: 1 addition & 1 deletion src/main/resources/secret
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package kr.codesquad.secondhand.acceptance;

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

import io.restassured.RestAssured;
import kr.codesquad.secondhand.domain.member.Member;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import java.util.Map;

public class FcmAcceptanceTest extends AcceptanceTestSupport {

@DisplayName("FCM 토큰을 저장하는데 성공한다.")
@Test
void saveFcmToken() {
// given
Member member = signup();
String tokenValue = "testTokenValue";

var request = RestAssured
.given().log().all()
.header(HttpHeaders.AUTHORIZATION, "Bearer " + jwtProvider.createAccessToken(member.getId()))
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(Map.of("token", tokenValue));

// when
var response = request
.patch("/api/fcm-token")
.then().log().all()
.extract();

// then
assertThat(response.statusCode()).isEqualTo(200);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package kr.codesquad.secondhand.application.firebase;

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

import kr.codesquad.secondhand.application.ApplicationTestSupport;
import kr.codesquad.secondhand.domain.member.Member;
import kr.codesquad.secondhand.fixture.FixtureFactory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;

class FcmTokenServiceTest extends ApplicationTestSupport {

@Autowired
private FcmTokenService fcmTokenService;

@Autowired
private RedisTemplate<String, Object> redisTemplate;

@DisplayName("토큰을 저장하는데 성공한다.")
@Test
void givenTokenValue_whenUpdateToken_thenSuccess() {
// given
Member member = supportRepository.save(FixtureFactory.createMember());
String tokenValue = "testTokenValue";

// when
fcmTokenService.updateToken(tokenValue, member.getId());

// then
String token = (String) redisTemplate.opsForValue().get("fcm_token:" + member.getId());
assertThat(token).isNotBlank();
}
}
35 changes: 35 additions & 0 deletions src/test/java/kr/codesquad/secondhand/config/FcmTestConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package kr.codesquad.secondhand.config;

import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging;
import kr.codesquad.secondhand.infrastructure.fcm.MockGoogleCredentials;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;

@ActiveProfiles("test")
@Configuration
public class FcmTestConfig {

@Bean
public FirebaseMessaging firebaseMessaging() {
List<FirebaseApp> apps = FirebaseApp.getApps();

return apps.stream()
.filter(app -> app.getName().equals(FirebaseApp.DEFAULT_APP_NAME))
.map(FirebaseMessaging::getInstance)
.findFirst()
.orElseGet(this::createFirebaseMessaging);
}

private FirebaseMessaging createFirebaseMessaging() {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(new MockGoogleCredentials("test-token"))
.setProjectId("test-project-id")
.build();

return FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options));
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
package kr.codesquad.secondhand.documentation;

import kr.codesquad.secondhand.presentation.*;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import kr.codesquad.secondhand.presentation.AuthController;
import kr.codesquad.secondhand.presentation.CategoryController;
import kr.codesquad.secondhand.presentation.ChatController;
import kr.codesquad.secondhand.presentation.ItemController;
import kr.codesquad.secondhand.presentation.MemberController;
import kr.codesquad.secondhand.presentation.ResidenceController;
import kr.codesquad.secondhand.presentation.SalesHistoryController;
import kr.codesquad.secondhand.presentation.WishItemController;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
Expand All @@ -25,7 +18,8 @@
MemberController.class,
ResidenceController.class,
SalesHistoryController.class,
WishItemController.class
WishItemController.class,
FcmController.class
})
@AutoConfigureRestDocs
public @interface DocumentationTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
import static org.mockito.BDDMockito.given;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Optional;
import kr.codesquad.secondhand.application.auth.AuthService;
import kr.codesquad.secondhand.application.auth.TokenService;
import kr.codesquad.secondhand.application.category.CategoryService;
import kr.codesquad.secondhand.application.chat.ChatLogService;
import kr.codesquad.secondhand.application.chat.ChatRoomService;
import kr.codesquad.secondhand.application.firebase.FcmTokenService;
import kr.codesquad.secondhand.application.image.ImageService;
import kr.codesquad.secondhand.application.item.ItemReadFacade;
import kr.codesquad.secondhand.application.item.ItemService;
Expand All @@ -24,6 +24,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;

@MockBean({
ChatLogService.class,
Expand All @@ -38,7 +39,8 @@
ResidenceService.class,
WishItemService.class,
SalesHistoryService.class,
ItemReadFacade.class
ItemReadFacade.class,
FcmTokenService.class
})
@DocumentationTest
public abstract class DocumentationTestSupport {
Expand Down
Loading

0 comments on commit 23b8fea

Please sign in to comment.