diff --git a/go.mod b/go.mod index cb09af0..2189653 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/oapi-codegen/gin-middleware v1.0.2 github.com/oapi-codegen/runtime v1.1.2 + github.com/stretchr/testify v1.11.1 google.golang.org/api v0.231.0 ) @@ -34,6 +35,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect @@ -72,6 +74,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.55.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/internal/handler/announcement.go b/internal/handler/announcement.go index 1841ed4..e867cc0 100644 --- a/internal/handler/announcement.go +++ b/internal/handler/announcement.go @@ -78,6 +78,7 @@ func (h *Handler) AnnouncementsV1Delete(c *gin.Context, id string) { } c.Status(http.StatusNoContent) + c.Writer.WriteHeaderNow() } // AnnouncementsV1Update 更新する diff --git a/internal/handler/announcement_test.go b/internal/handler/announcement_test.go new file mode 100644 index 0000000..4ceefba --- /dev/null +++ b/internal/handler/announcement_test.go @@ -0,0 +1,557 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "firebase.google.com/go/v4/auth" + api "github.com/fun-dotto/api-template/generated" + "github.com/fun-dotto/api-template/internal/handler" + "github.com/fun-dotto/api-template/internal/middleware" + "github.com/fun-dotto/api-template/internal/repository" + "github.com/fun-dotto/api-template/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupTestContext Firebase認証をモックしたテストコンテキストを作成する +func setupTestContext(withAdminClaim bool) (*httptest.ResponseRecorder, *gin.Context) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // Requestを初期化 + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + + if withAdminClaim { + // Firebaseトークンをモック + token := &auth.Token{ + Claims: map[string]interface{}{ + "admin": true, + }, + } + c.Set(middleware.FirebaseTokenContextKey, token) + } + + return w, c +} + +// setupTestContextWithClaims 指定したクレームでFirebaseトークンをモックしたテストコンテキストを作成する(403など権限不足の検証用) +func setupTestContextWithClaims(claims map[string]interface{}) (*httptest.ResponseRecorder, *gin.Context) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + token := &auth.Token{Claims: claims} + c.Set(middleware.FirebaseTokenContextKey, token) + return w, c +} + +func TestAnnouncementsV1List(t *testing.T) { + tests := []struct { + name string + withAdminClaim bool + withDeveloperClaim bool + customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) + wantCode int + validate func(t *testing.T, w *httptest.ResponseRecorder) + }{ + { + name: "正常にお知らせ一覧が取得できる", + withAdminClaim: true, + wantCode: http.StatusOK, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err, "JSONのパースに失敗しました") + + announcements, ok := response["announcements"].([]interface{}) + assert.True(t, ok, "announcementsフィールドが配列ではありません") + assert.NotEmpty(t, announcements, "アナウンスメントが空です") + }, + }, + { + name: "developerクレームのみでも一覧が取得できる", + withDeveloperClaim: true, + wantCode: http.StatusOK, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err, "JSONのパースに失敗しました") + announcements, ok := response["announcements"].([]interface{}) + assert.True(t, ok, "announcementsフィールドが配列ではありません") + assert.NotEmpty(t, announcements, "アナウンスメントが空です") + assert.Len(t, announcements, 1, "MockRepositoryは1件返すはずです") + }, + }, + { + name: "Content-Typeがapplication/jsonである", + withAdminClaim: true, + wantCode: http.StatusOK, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + }, + }, + { + name: "レスポンスが正しい構造である", + withAdminClaim: true, + wantCode: http.StatusOK, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + announcements, ok := response["announcements"].([]interface{}) + assert.True(t, ok, "announcementsフィールドが配列ではありません") + assert.Len(t, announcements, 1, "MockRepositoryは1件返すはずです") + }, + }, + { + name: "お知らせのフィールドが正しく返される", + withAdminClaim: true, + wantCode: http.StatusOK, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response struct { + Announcements []api.AnnouncementServiceAnnouncement `json:"announcements"` + } + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Len(t, response.Announcements, 1, "MockRepositoryは1件返すはずです") + assert.Equal(t, "1", response.Announcements[0].Id) + assert.Equal(t, "お知らせ1", response.Announcements[0].Title) + assert.Equal(t, "https://example.com/1", response.Announcements[0].Url) + assert.False(t, response.Announcements[0].AvailableFrom.IsZero(), "AvailableFromが設定されていること") + assert.False(t, response.Announcements[0].AvailableUntil.IsZero(), "AvailableUntilが設定されていること") + }, + }, + { + name: "認証トークンがない場合は401エラー", + withAdminClaim: false, + wantCode: http.StatusUnauthorized, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Authentication required", response["error"]) + }, + }, + { + name: "admin/developer以外のクレームのみのトークンでは403エラー", + customClaims: map[string]interface{}{"user": true}, + wantCode: http.StatusForbidden, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Insufficient permissions", response["error"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := repository.NewMockAnnouncementRepository() + announcementService := service.NewAnnouncementService(mockRepo) + h := handler.NewHandler(announcementService) + var w *httptest.ResponseRecorder + var c *gin.Context + if tt.customClaims != nil { + w, c = setupTestContextWithClaims(tt.customClaims) + } else if tt.withDeveloperClaim { + w, c = setupTestContextWithClaims(map[string]interface{}{"developer": true}) + } else { + w, c = setupTestContext(tt.withAdminClaim) + } + + h.AnnouncementsV1List(c) + + assert.Equal(t, tt.wantCode, w.Code) + + if tt.validate != nil { + tt.validate(t, w) + } + }) + } +} + +func TestAnnouncementsV1Detail(t *testing.T) { + tests := []struct { + name string + id string + withAdminClaim bool + customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) + wantCode int + validate func(t *testing.T, w *httptest.ResponseRecorder) + }{ + { + name: "正常にお知らせ詳細が取得できる", + id: "1", + withAdminClaim: true, + wantCode: http.StatusOK, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response struct { + Announcement api.AnnouncementServiceAnnouncement `json:"announcement"` + } + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err, "JSONのパースに失敗しました") + assert.Equal(t, "1", response.Announcement.Id) + assert.Equal(t, "お知らせ1", response.Announcement.Title) + assert.Equal(t, "https://example.com/1", response.Announcement.Url) + }, + }, + { + name: "認証トークンがない場合は401エラー", + id: "1", + withAdminClaim: false, + wantCode: http.StatusUnauthorized, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Authentication required", response["error"]) + }, + }, + { + name: "admin/developer以外のクレームのみのトークンでは403エラー", + id: "1", + customClaims: map[string]interface{}{"user": true}, + wantCode: http.StatusForbidden, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Insufficient permissions", response["error"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := repository.NewMockAnnouncementRepository() + announcementService := service.NewAnnouncementService(mockRepo) + h := handler.NewHandler(announcementService) + var w *httptest.ResponseRecorder + var c *gin.Context + if tt.customClaims != nil { + w, c = setupTestContextWithClaims(tt.customClaims) + } else { + w, c = setupTestContext(tt.withAdminClaim) + } + + h.AnnouncementsV1Detail(c, tt.id) + + assert.Equal(t, tt.wantCode, w.Code) + + if tt.validate != nil { + tt.validate(t, w) + } + }) + } +} + +func TestAnnouncementsV1Create(t *testing.T) { + now := time.Now() + until := now.Add(24 * time.Hour) + + tests := []struct { + name string + request api.AnnouncementServiceAnnouncementRequest + withAdminClaim bool + withDeveloperClaim bool + customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) + wantCode int + validate func(t *testing.T, w *httptest.ResponseRecorder) + }{ + { + name: "正常にお知らせを作成できる", + request: api.AnnouncementServiceAnnouncementRequest{ + Title: "新しいお知らせ", + Url: "https://example.com/new", + AvailableFrom: now, + AvailableUntil: &until, + }, + withAdminClaim: true, + wantCode: http.StatusCreated, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response struct { + Announcement api.AnnouncementServiceAnnouncement `json:"announcement"` + } + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err, "JSONのパースに失敗しました") + assert.Equal(t, "created-id", response.Announcement.Id) + assert.Equal(t, "新しいお知らせ", response.Announcement.Title) + assert.Equal(t, "https://example.com/new", response.Announcement.Url) + }, + }, + { + name: "developerクレームのみでも作成できる", + request: api.AnnouncementServiceAnnouncementRequest{ + Title: "developer経由のお知らせ", + Url: "https://example.com/developer", + AvailableFrom: now, + AvailableUntil: &until, + }, + withDeveloperClaim: true, + wantCode: http.StatusCreated, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response struct { + Announcement api.AnnouncementServiceAnnouncement `json:"announcement"` + } + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err, "JSONのパースに失敗しました") + assert.Equal(t, "created-id", response.Announcement.Id) + assert.Equal(t, "developer経由のお知らせ", response.Announcement.Title) + assert.Equal(t, "https://example.com/developer", response.Announcement.Url) + }, + }, + { + name: "認証トークンがない場合は401エラー", + request: api.AnnouncementServiceAnnouncementRequest{ + Title: "新しいお知らせ", + Url: "https://example.com/new", + AvailableFrom: now, + AvailableUntil: &until, + }, + withAdminClaim: false, + wantCode: http.StatusUnauthorized, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Authentication required", response["error"]) + }, + }, + { + name: "admin/developer以外のクレームのみのトークンでは403エラー", + request: api.AnnouncementServiceAnnouncementRequest{ + Title: "新しいお知らせ", + Url: "https://example.com/new", + AvailableFrom: now, + AvailableUntil: &until, + }, + customClaims: map[string]interface{}{"user": true}, + wantCode: http.StatusForbidden, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Insufficient permissions", response["error"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := repository.NewMockAnnouncementRepository() + announcementService := service.NewAnnouncementService(mockRepo) + h := handler.NewHandler(announcementService) + var w *httptest.ResponseRecorder + var c *gin.Context + if tt.customClaims != nil { + w, c = setupTestContextWithClaims(tt.customClaims) + } else if tt.withDeveloperClaim { + w, c = setupTestContextWithClaims(map[string]interface{}{"developer": true}) + } else { + w, c = setupTestContext(tt.withAdminClaim) + } + + // リクエストボディを設定 + body, err := json.Marshal(tt.request) + require.NoError(t, err, "リクエストボディのJSONエンコードに失敗しました") + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/announcements", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.AnnouncementsV1Create(c) + + assert.Equal(t, tt.wantCode, w.Code) + + if tt.validate != nil { + tt.validate(t, w) + } + }) + } +} + +func TestAnnouncementsV1Update(t *testing.T) { + now := time.Now() + until := now.Add(24 * time.Hour) + + tests := []struct { + name string + id string + request api.AnnouncementServiceAnnouncementRequest + withAdminClaim bool + customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) + wantCode int + validate func(t *testing.T, w *httptest.ResponseRecorder) + }{ + { + name: "正常にお知らせを更新できる", + id: "1", + request: api.AnnouncementServiceAnnouncementRequest{ + Title: "更新されたお知らせ", + Url: "https://example.com/updated", + AvailableFrom: now, + AvailableUntil: &until, + }, + withAdminClaim: true, + wantCode: http.StatusOK, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response struct { + Announcement api.AnnouncementServiceAnnouncement `json:"announcement"` + } + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err, "JSONのパースに失敗しました") + assert.Equal(t, "1", response.Announcement.Id) + assert.Equal(t, "更新されたお知らせ", response.Announcement.Title) + assert.Equal(t, "https://example.com/updated", response.Announcement.Url) + }, + }, + { + name: "認証トークンがない場合は401エラー", + id: "1", + request: api.AnnouncementServiceAnnouncementRequest{ + Title: "更新されたお知らせ", + Url: "https://example.com/updated", + AvailableFrom: now, + AvailableUntil: &until, + }, + withAdminClaim: false, + wantCode: http.StatusUnauthorized, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Authentication required", response["error"]) + }, + }, + { + name: "admin/developer以外のクレームのみのトークンでは403エラー", + id: "1", + request: api.AnnouncementServiceAnnouncementRequest{ + Title: "更新されたお知らせ", + Url: "https://example.com/updated", + AvailableFrom: now, + AvailableUntil: &until, + }, + customClaims: map[string]interface{}{"user": true}, + wantCode: http.StatusForbidden, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Insufficient permissions", response["error"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := repository.NewMockAnnouncementRepository() + announcementService := service.NewAnnouncementService(mockRepo) + h := handler.NewHandler(announcementService) + var w *httptest.ResponseRecorder + var c *gin.Context + if tt.customClaims != nil { + w, c = setupTestContextWithClaims(tt.customClaims) + } else { + w, c = setupTestContext(tt.withAdminClaim) + } + + // リクエストボディを設定 + body, err := json.Marshal(tt.request) + require.NoError(t, err, "リクエストボディのJSONエンコードに失敗しました") + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/announcements/"+tt.id, bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.AnnouncementsV1Update(c, tt.id) + + assert.Equal(t, tt.wantCode, w.Code) + + if tt.validate != nil { + tt.validate(t, w) + } + }) + } +} + +func TestAnnouncementsV1Delete(t *testing.T) { + tests := []struct { + name string + id string + withAdminClaim bool + withDeveloperClaim bool + customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) + wantCode int + validate func(t *testing.T, w *httptest.ResponseRecorder) + }{ + { + name: "正常にお知らせを削除できる", + id: "1", + withAdminClaim: true, + wantCode: http.StatusNoContent, + validate: nil, + }, + { + name: "developerクレームのみでも削除できる", + id: "1", + withDeveloperClaim: true, + wantCode: http.StatusNoContent, + validate: nil, + }, + { + name: "認証トークンがない場合は401エラー", + id: "1", + withAdminClaim: false, + wantCode: http.StatusUnauthorized, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Authentication required", response["error"]) + }, + }, + { + name: "admin/developer以外のクレームのみのトークンでは403エラー", + id: "1", + customClaims: map[string]interface{}{"user": true}, + wantCode: http.StatusForbidden, + validate: func(t *testing.T, w *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Insufficient permissions", response["error"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := repository.NewMockAnnouncementRepository() + announcementService := service.NewAnnouncementService(mockRepo) + h := handler.NewHandler(announcementService) + var w *httptest.ResponseRecorder + var c *gin.Context + if tt.customClaims != nil { + w, c = setupTestContextWithClaims(tt.customClaims) + } else if tt.withDeveloperClaim { + w, c = setupTestContextWithClaims(map[string]interface{}{"developer": true}) + } else { + w, c = setupTestContext(tt.withAdminClaim) + } + + h.AnnouncementsV1Delete(c, tt.id) + + assert.Equal(t, tt.wantCode, w.Code) + + if tt.validate != nil { + tt.validate(t, w) + } + }) + } +} diff --git a/internal/repository/announcement_mock.go b/internal/repository/announcement_mock.go new file mode 100644 index 0000000..85bb253 --- /dev/null +++ b/internal/repository/announcement_mock.go @@ -0,0 +1,71 @@ +package repository + +import ( + "context" + "time" + + "github.com/fun-dotto/api-template/internal/domain" + "github.com/fun-dotto/api-template/internal/service" +) + +type mockAnnouncementRepository struct{} + +// NewMockAnnouncementRepository モックリポジトリを作成する +func NewMockAnnouncementRepository() service.AnnouncementRepository { + return &mockAnnouncementRepository{} +} + +// List 一覧を取得する(モック) +func (r *mockAnnouncementRepository) List(ctx context.Context) ([]domain.Announcement, error) { + now := time.Now() + until := now.Add(24 * time.Hour) + return []domain.Announcement{ + { + ID: "1", + Title: "お知らせ1", + URL: "https://example.com/1", + AvailableFrom: now, + AvailableUntil: &until, + }, + }, nil +} + +// Detail 詳細を取得する(モック) +func (r *mockAnnouncementRepository) Detail(ctx context.Context, id string) (*domain.Announcement, error) { + now := time.Now() + until := now.Add(24 * time.Hour) + return &domain.Announcement{ + ID: id, + Title: "お知らせ" + id, + URL: "https://example.com/" + id, + AvailableFrom: now, + AvailableUntil: &until, + }, nil +} + +// Create 新規作成する(モック) +func (r *mockAnnouncementRepository) Create(ctx context.Context, req *domain.AnnouncementRequest) (*domain.Announcement, error) { + return &domain.Announcement{ + ID: "created-id", + Title: req.Title, + URL: req.URL, + AvailableFrom: req.AvailableFrom, + AvailableUntil: req.AvailableUntil, + }, nil +} + +// Update 更新する(モック) +func (r *mockAnnouncementRepository) Update(ctx context.Context, id string, req *domain.AnnouncementRequest) (*domain.Announcement, error) { + return &domain.Announcement{ + ID: id, + Title: req.Title, + URL: req.URL, + AvailableFrom: req.AvailableFrom, + AvailableUntil: req.AvailableUntil, + }, nil +} + +// Delete 削除する(モック) +func (r *mockAnnouncementRepository) Delete(ctx context.Context, id string) error { + return nil +}