diff --git a/api/agenda/agenda_handler_test.go b/api/agenda/agenda_handler_test.go index a4fdfb4..5ef4d49 100644 --- a/api/agenda/agenda_handler_test.go +++ b/api/agenda/agenda_handler_test.go @@ -255,3 +255,189 @@ func TestAgendaReviewHandlerInvalidStatus(t *testing.T) { }) assert.Equal(t, http.StatusBadRequest, w.Code) } + +func TestAgendaReviewHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 40) + attendee := testutil.SeedUser(t, testutil.RandomEmail(), 10) + event := seedEventWithAttendee(t, owner, attendee) + manager := testutil.SeedUser(t, testutil.RandomEmail(), 30) + managerToken := issueToken(t, manager.UserId) + attendeeToken := issueToken(t, attendee.UserId) + r := newAgendaRouter(t) + + submitW := postWithBearer(t, r, "/agenda/submit", attendeeToken, service_agenda.SubmitData{ + EventId: event.EventId, + Name: "Talk to Review", + Description: "desc", + }) + require.Equal(t, http.StatusOK, submitW.Code) + var submitResp map[string]any + require.NoError(t, json.Unmarshal(submitW.Body.Bytes(), &submitResp)) + agendaId := submitResp["data"].(map[string]any)["agenda_id"].(string) + + w := patchWithBearer(t, r, "/agenda/review", managerToken, service_agenda.AgendaReviewData{ + EventId: event.EventId, + AgendaId: uuid.MustParse(agendaId), + Status: "approved", + }) + assert.Equal(t, http.StatusOK, w.Code) +} + +// ---- List (success) ---- + +func TestAgendaListHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 40) + attendee := testutil.SeedUser(t, testutil.RandomEmail(), 10) + event := seedEventWithAttendee(t, owner, attendee) + manager := testutil.SeedUser(t, testutil.RandomEmail(), 30) + managerToken := issueToken(t, manager.UserId) + attendeeToken := issueToken(t, attendee.UserId) + r := newAgendaRouter(t) + + postWithBearer(t, r, "/agenda/submit", attendeeToken, service_agenda.SubmitData{ + EventId: event.EventId, + Name: "Talk 1", + }) + + w := getWithBearer(t, r, "/agenda/list?event_id="+event.EventId.String(), managerToken) + assert.Equal(t, http.StatusOK, w.Code) +} + +// ---- Update ---- + +func TestAgendaUpdateHandlerMissingAgendaId(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, user.UserId) + r := newAgendaRouter(t) + + w := patchWithBearer(t, r, "/agenda/update", token, map[string]any{ + "name": "New Name", + }) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestAgendaUpdateHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 40) + attendee := testutil.SeedUser(t, testutil.RandomEmail(), 10) + event := seedEventWithAttendee(t, owner, attendee) + attendeeToken := issueToken(t, attendee.UserId) + r := newAgendaRouter(t) + + submitW := postWithBearer(t, r, "/agenda/submit", attendeeToken, service_agenda.SubmitData{ + EventId: event.EventId, + Name: "Original Talk", + Description: "desc", + }) + require.Equal(t, http.StatusOK, submitW.Code) + var submitResp map[string]any + require.NoError(t, json.Unmarshal(submitW.Body.Bytes(), &submitResp)) + agendaId := submitResp["data"].(map[string]any)["agenda_id"].(string) + + newName := "Updated Talk" + w := patchWithBearer(t, r, "/agenda/update", attendeeToken, service_agenda.AgendaUpdateData{ + AgendaId: uuid.MustParse(agendaId), + Name: &newName, + }) + assert.Equal(t, http.StatusOK, w.Code) +} + +// ---- Schedule PATCH ---- + +func TestAgendaScheduleHandlerMissingAgendaId(t *testing.T) { + testutil.SetupWithAuth(t) + manager := testutil.SeedUser(t, testutil.RandomEmail(), 30) + token := issueToken(t, manager.UserId) + r := newAgendaRouter(t) + + w := patchWithBearer(t, r, "/agenda/schedule", token, map[string]any{ + "start_time": time.Now().Add(time.Hour), + "end_time": time.Now().Add(2 * time.Hour), + }) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestAgendaScheduleHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 40) + attendee := testutil.SeedUser(t, testutil.RandomEmail(), 10) + event := seedEventWithAttendee(t, owner, attendee) + manager := testutil.SeedUser(t, testutil.RandomEmail(), 30) + managerToken := issueToken(t, manager.UserId) + attendeeToken := issueToken(t, attendee.UserId) + r := newAgendaRouter(t) + + submitW := postWithBearer(t, r, "/agenda/submit", attendeeToken, service_agenda.SubmitData{ + EventId: event.EventId, + Name: "Schedule Me", + Description: "base64desc", + }) + require.Equal(t, http.StatusOK, submitW.Code) + var submitResp map[string]any + require.NoError(t, json.Unmarshal(submitW.Body.Bytes(), &submitResp)) + agendaId := submitResp["data"].(map[string]any)["agenda_id"].(string) + + reviewW := patchWithBearer(t, r, "/agenda/review", managerToken, service_agenda.AgendaReviewData{ + EventId: event.EventId, + AgendaId: uuid.MustParse(agendaId), + Status: "approved", + }) + require.Equal(t, http.StatusOK, reviewW.Code) + + now := time.Now() + w := patchWithBearer(t, r, "/agenda/schedule", managerToken, service_agenda.AgendaScheduleData{ + AgendaId: uuid.MustParse(agendaId), + StartTime: now.Add(time.Hour), + EndTime: now.Add(2 * time.Hour), + }) + assert.Equal(t, http.StatusOK, w.Code) +} + +// ---- ScheduleGet (success) ---- + +func TestAgendaScheduleGetHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 40) + attendee := testutil.SeedUser(t, testutil.RandomEmail(), 10) + event := seedEventWithAttendee(t, owner, attendee) + manager := testutil.SeedUser(t, testutil.RandomEmail(), 30) + managerToken := issueToken(t, manager.UserId) + attendeeToken := issueToken(t, attendee.UserId) + r := newAgendaRouter(t) + + submitW := postWithBearer(t, r, "/agenda/submit", attendeeToken, service_agenda.SubmitData{ + EventId: event.EventId, + Name: "Published Talk", + Description: "base64desc", + }) + require.Equal(t, http.StatusOK, submitW.Code) + var submitResp map[string]any + require.NoError(t, json.Unmarshal(submitW.Body.Bytes(), &submitResp)) + agendaId := submitResp["data"].(map[string]any)["agenda_id"].(string) + + patchWithBearer(t, r, "/agenda/review", managerToken, service_agenda.AgendaReviewData{ + EventId: event.EventId, + AgendaId: uuid.MustParse(agendaId), + Status: "approved", + }) + + now := time.Now() + patchWithBearer(t, r, "/agenda/schedule", managerToken, service_agenda.AgendaScheduleData{ + AgendaId: uuid.MustParse(agendaId), + StartTime: now.Add(time.Hour), + EndTime: now.Add(2 * time.Hour), + }) + + require.NoError(t, new(data.Event).PatchByEventId(t.Context(), event.EventId, data.WithIsAgendaPublished(true))) + + w := getWithBearer(t, r, "/agenda/schedule?event_id="+event.EventId.String(), attendeeToken) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + items, ok := resp["data"].([]any) + require.True(t, ok) + assert.Len(t, items, 1) +} diff --git a/api/auth/auth_handler_test.go b/api/auth/auth_handler_test.go index 84e89a4..91738f7 100644 --- a/api/auth/auth_handler_test.go +++ b/api/auth/auth_handler_test.go @@ -205,3 +205,47 @@ func TestExchangeHandlerNoAuth(t *testing.T) { // No auth header → 401 assert.Equal(t, http.StatusUnauthorized, w.Code) } + +func TestExchangeHandlerSuccess(t *testing.T) { + r := setupAuthRouter(t) + + // Step 1: Magic (debug mode, returns redirect URI with code) + magicW := postJSON(t, r, "/auth/magic", service_auth.MagicData{ + ClientId: testutil.TestClientID, + RedirectUri: "http://localhost/callback", + State: "s", + Email: "exchange@example.com", + }) + require.Equal(t, http.StatusOK, magicW.Code) + var magicResp map[string]any + require.NoError(t, json.Unmarshal(magicW.Body.Bytes(), &magicResp)) + rawURI := magicResp["data"].(map[string]any)["uri"].(string) + code := extractQueryParam(t, rawURI, "code") + + // Step 2: Redirect → produces token code + redirectW := getRequest(t, r, "/auth/redirect?client_id="+testutil.TestClientID+ + "&redirect_uri=http://localhost/callback&code="+code+"&state=s") + require.Equal(t, http.StatusFound, redirectW.Code) + location := redirectW.Header().Get("Location") + tokenCode := extractQueryParam(t, location, "code") + + // Step 3: Token → get access token + tokenW := postJSON(t, r, "/auth/token", service_auth.TokenData{Code: tokenCode}) + require.Equal(t, http.StatusOK, tokenW.Code) + var tokenResp map[string]any + require.NoError(t, json.Unmarshal(tokenW.Body.Bytes(), &tokenResp)) + accessToken := tokenResp["data"].(map[string]any)["access_token"].(string) + + // Step 4: Exchange (requires JWT Bearer token) + w := postWithBearer(t, r, "/auth/exchange", accessToken, service_auth.ExchangeData{ + ClientId: testutil.TestClientID, + RedirectUri: "http://localhost/callback", + State: "s", + }) + assert.Equal(t, http.StatusOK, w.Code) + var exchangeResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &exchangeResp)) + exchangeData, ok := exchangeResp["data"].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, exchangeData["redirect_uri"]) +} diff --git a/api/event/event_handler_test.go b/api/event/event_handler_test.go index d6fe109..65b602f 100644 --- a/api/event/event_handler_test.go +++ b/api/event/event_handler_test.go @@ -227,3 +227,382 @@ func TestEventDeleteHandlerNotFound(t *testing.T) { }) assert.Equal(t, http.StatusNotFound, w.Code) } + +func TestEventDeleteHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 40) + token := issueToken(t, owner.UserId) + r := newEventRouter(t) + + createW := postWithBearer(t, r, "/event/create", token, service_event.EventCreateData{ + Type: "party", + Name: "Delete Me", + Subtitle: "sub", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(48 * time.Hour), + Quota: 100, + Limit: 150, + }) + require.Equal(t, http.StatusOK, createW.Code) + var createResp map[string]any + require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &createResp)) + eventId := createResp["data"].(map[string]any)["event_id"].(string) + + w := deleteWithBearer(t, r, "/event/delete", token, map[string]any{"event_id": eventId}) + assert.Equal(t, http.StatusOK, w.Code) +} + +// ---- Info (success) ---- + +func TestEventInfoHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 30) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + ownerToken := issueToken(t, owner.UserId) + userToken := issueToken(t, user.UserId) + r := newEventRouter(t) + + createW := postWithBearer(t, r, "/event/create", ownerToken, service_event.EventCreateData{ + Type: "party", + Name: "Info Event", + Subtitle: "sub", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(48 * time.Hour), + Quota: 100, + Limit: 150, + }) + require.Equal(t, http.StatusOK, createW.Code) + var createResp map[string]any + require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &createResp)) + eventId := createResp["data"].(map[string]any)["event_id"].(string) + + w := getWithBearer(t, r, "/event/info?event_id="+eventId, userToken) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + d, ok := resp["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, eventId, d["event_id"]) +} + +// ---- Join (success) ---- + +func TestEventJoinHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 30) + joiner := testutil.SeedUser(t, testutil.RandomEmail(), 10) + require.NoError(t, new(data.User).PatchByUserId(t.Context(), joiner.UserId, data.WithNickname("Joiner"))) + + ownerToken := issueToken(t, owner.UserId) + joinerToken := issueToken(t, joiner.UserId) + r := newEventRouter(t) + + createW := postWithBearer(t, r, "/event/create", ownerToken, service_event.EventCreateData{ + Type: "party", + Name: "Join Event", + Subtitle: "sub", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(48 * time.Hour), + Quota: 100, + Limit: 150, + }) + require.Equal(t, http.StatusOK, createW.Code) + var createResp map[string]any + require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &createResp)) + eventId := createResp["data"].(map[string]any)["event_id"].(string) + + w := postWithBearer(t, r, "/event/join", joinerToken, map[string]any{"event_id": eventId}) + assert.Equal(t, http.StatusOK, w.Code) +} + +// ---- Update ---- + +func TestEventUpdateHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 30) + token := issueToken(t, owner.UserId) + r := newEventRouter(t) + + createW := postWithBearer(t, r, "/event/create", token, service_event.EventCreateData{ + Type: "party", + Name: "Original Name", + Subtitle: "sub", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(48 * time.Hour), + Quota: 100, + Limit: 150, + }) + require.Equal(t, http.StatusOK, createW.Code) + var createResp map[string]any + require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &createResp)) + eventId := createResp["data"].(map[string]any)["event_id"].(string) + + newName := "Updated Name" + w := patchWithBearer(t, r, "/event/update", token, map[string]any{ + "event_id": eventId, + "name": newName, + }) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestEventUpdateHandlerInvalidJSON(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 30) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + req := httptest.NewRequest(http.MethodPatch, "/event/update", bytes.NewBufferString("{bad")) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// ---- Stats ---- + +func TestEventStatsHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 30) + token := issueToken(t, owner.UserId) + r := newEventRouter(t) + + createW := postWithBearer(t, r, "/event/create", token, service_event.EventCreateData{ + Type: "party", + Name: "Stats Event", + Subtitle: "sub", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(48 * time.Hour), + Quota: 100, + Limit: 150, + }) + require.Equal(t, http.StatusOK, createW.Code) + var createResp map[string]any + require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &createResp)) + eventId := createResp["data"].(map[string]any)["event_id"].(string) + + w := getWithBearer(t, r, "/event/stats?event_id="+eventId, token) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + d, ok := resp["data"].(map[string]any) + require.True(t, ok) + assert.NotNil(t, d["join_count"]) +} + +func TestEventStatsHandlerMissingEventId(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 30) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + w := getWithBearer(t, r, "/event/stats?event_id=not-a-uuid", token) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// ---- Checkin ---- + +func TestEventCheckinHandlerInvalidEventId(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + w := getWithBearer(t, r, "/event/checkin?event_id=not-a-uuid", token) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestEventCheckinHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 30) + joiner := testutil.SeedUser(t, testutil.RandomEmail(), 10) + require.NoError(t, new(data.User).PatchByUserId(t.Context(), joiner.UserId, data.WithNickname("Joiner"))) + + ownerToken := issueToken(t, owner.UserId) + joinerToken := issueToken(t, joiner.UserId) + r := newEventRouter(t) + + createW := postWithBearer(t, r, "/event/create", ownerToken, service_event.EventCreateData{ + Type: "party", + Name: "Checkin Event", + Subtitle: "sub", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(48 * time.Hour), + Quota: 100, + Limit: 150, + }) + require.Equal(t, http.StatusOK, createW.Code) + var createResp map[string]any + require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &createResp)) + eventId := createResp["data"].(map[string]any)["event_id"].(string) + + joinW := postWithBearer(t, r, "/event/join", joinerToken, map[string]any{"event_id": eventId}) + require.Equal(t, http.StatusOK, joinW.Code) + + w := getWithBearer(t, r, "/event/checkin?event_id="+eventId, joinerToken) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + d, ok := resp["data"].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, d["checkin_code"]) +} + +// ---- CheckinSubmit ---- + +func TestEventCheckinSubmitHandlerInvalidCode(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 20) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + w := postWithBearer(t, r, "/event/checkin/submit", token, service_event.CheckinSubmitData{ + CheckinCode: "000000", + }) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestEventCheckinSubmitHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 30) + joiner := testutil.SeedUser(t, testutil.RandomEmail(), 10) + require.NoError(t, new(data.User).PatchByUserId(t.Context(), joiner.UserId, data.WithNickname("Joiner"))) + + ownerToken := issueToken(t, owner.UserId) + joinerToken := issueToken(t, joiner.UserId) + r := newEventRouter(t) + + createW := postWithBearer(t, r, "/event/create", ownerToken, service_event.EventCreateData{ + Type: "party", + Name: "Checkin Submit Event", + Subtitle: "sub", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(48 * time.Hour), + Quota: 100, + Limit: 150, + }) + require.Equal(t, http.StatusOK, createW.Code) + var createResp map[string]any + require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &createResp)) + eventId := createResp["data"].(map[string]any)["event_id"].(string) + + joinW := postWithBearer(t, r, "/event/join", joinerToken, map[string]any{"event_id": eventId}) + require.Equal(t, http.StatusOK, joinW.Code) + + checkinW := getWithBearer(t, r, "/event/checkin?event_id="+eventId, joinerToken) + require.Equal(t, http.StatusOK, checkinW.Code) + var checkinResp map[string]any + require.NoError(t, json.Unmarshal(checkinW.Body.Bytes(), &checkinResp)) + code := checkinResp["data"].(map[string]any)["checkin_code"].(string) + + w := postWithBearer(t, r, "/event/checkin/submit", ownerToken, service_event.CheckinSubmitData{ + CheckinCode: code, + }) + assert.Equal(t, http.StatusOK, w.Code) +} + +// ---- CheckinQuery ---- + +func TestEventCheckinQueryHandlerInvalidEventId(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + w := getWithBearer(t, r, "/event/checkin/query?event_id=not-a-uuid", token) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestEventCheckinQueryHandlerNotJoined(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + w := getWithBearer(t, r, "/event/checkin/query?event_id="+uuid.New().String(), token) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// ---- Attendance ---- + +func TestEventAttendanceHandlerInvalidEventId(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 30) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + w := getWithBearer(t, r, "/event/attendance?event_id=not-a-uuid", token) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestEventAttendanceHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + owner := testutil.SeedUser(t, testutil.RandomEmail(), 30) + token := issueToken(t, owner.UserId) + r := newEventRouter(t) + + createW := postWithBearer(t, r, "/event/create", token, service_event.EventCreateData{ + Type: "party", + Name: "Attendance Event", + Subtitle: "sub", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(48 * time.Hour), + Quota: 100, + Limit: 150, + }) + require.Equal(t, http.StatusOK, createW.Code) + var createResp map[string]any + require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &createResp)) + eventId := createResp["data"].(map[string]any)["event_id"].(string) + + w := getWithBearer(t, r, "/event/attendance?event_id="+eventId, token) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + d, ok := resp["data"].(map[string]any) + require.True(t, ok) + assert.NotNil(t, d["total"]) +} + +// ---- Guide ---- + +func TestEventGuideHandlerInvalidEventId(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + // The guide handler returns 500 for invalid UUID (source behaviour) + w := getWithBearer(t, r, "/event/guide?event_id=not-a-uuid", token) + assert.NotEqual(t, http.StatusNotFound, w.Code, "route must be registered") +} + +// ---- Permission enforcement ---- + +func TestEventCreateHandlerLowPermission(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + w := postWithBearer(t, r, "/event/create", token, service_event.EventCreateData{ + Type: "party", + Name: "Should Fail", + Quota: 10, + Limit: 20, + }) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestEventDeleteHandlerLowPermission(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 30) + token := issueToken(t, user.UserId) + r := newEventRouter(t) + + w := deleteWithBearer(t, r, "/event/delete", token, map[string]any{ + "event_id": uuid.New().String(), + }) + assert.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/api/user/user_handler_test.go b/api/user/user_handler_test.go index 7d8228d..9f6a740 100644 --- a/api/user/user_handler_test.go +++ b/api/user/user_handler_test.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "nixcn-cms/data" "nixcn-cms/internal/authtoken" "nixcn-cms/testutil" ) @@ -137,3 +138,90 @@ func TestUserListHandlerRequiresOffset(t *testing.T) { w := getWithBearer(t, r, "/user/list", token) assert.Equal(t, http.StatusBadRequest, w.Code) } + +func TestUserListHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + admin := testutil.SeedUser(t, testutil.RandomEmail(), 40) + for i := 0; i < 3; i++ { + testutil.SeedUser(t, testutil.RandomEmail(), 10) + } + token := issueToken(t, admin.UserId) + r := newUserRouter(t) + + w := getWithBearer(t, r, "/user/list?offset=0&limit=10", token) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.NotNil(t, resp["data"]) +} + +func TestUserListHandlerLowPermission(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, user.UserId) + r := newUserRouter(t) + + w := getWithBearer(t, r, "/user/list?offset=0", token) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// ---- Other (info by user_id) ---- + +func TestUserInfoHandlerOtherSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + caller := testutil.SeedUser(t, testutil.RandomEmail(), 10) + target := testutil.SeedUser(t, testutil.RandomEmail(), 10) + require.NoError(t, new(data.User).PatchByUserId(context.Background(), target.UserId, data.WithAllowPublic(true))) + + token := issueToken(t, caller.UserId) + r := newUserRouter(t) + + w := getWithBearer(t, r, "/user/info/"+target.UserId.String(), token) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + d, ok := resp["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, target.Email, d["email"]) +} + +// ---- AdminUpdate ---- + +func TestUserAdminUpdateHandlerSuccess(t *testing.T) { + testutil.SetupWithAuth(t) + admin := testutil.SeedUser(t, testutil.RandomEmail(), 40) + target := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, admin.UserId) + r := newUserRouter(t) + + w := patchWithBearer(t, r, "/user/update/"+target.UserId.String(), token, map[string]any{ + "nickname": "Admin Set Nickname", + }) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestUserAdminUpdateHandlerInvalidUserId(t *testing.T) { + testutil.SetupWithAuth(t) + admin := testutil.SeedUser(t, testutil.RandomEmail(), 40) + token := issueToken(t, admin.UserId) + r := newUserRouter(t) + + w := patchWithBearer(t, r, "/user/update/not-a-uuid", token, map[string]any{ + "nickname": "Test", + }) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserAdminUpdateHandlerLowPermission(t *testing.T) { + testutil.SetupWithAuth(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + target := testutil.SeedUser(t, testutil.RandomEmail(), 10) + token := issueToken(t, user.UserId) + r := newUserRouter(t) + + w := patchWithBearer(t, r, "/user/update/"+target.UserId.String(), token, map[string]any{ + "nickname": "Hacked", + }) + assert.Equal(t, http.StatusForbidden, w.Code) +} + diff --git a/config/config.go b/config/config.go index 234b59a..db51526 100644 --- a/config/config.go +++ b/config/config.go @@ -36,6 +36,6 @@ func Init() { conf := &config{} if err := viper.Unmarshal(conf); err != nil { - log.Fatalln("[Condig] Can't unmarshal config!") + log.Fatalln("[Config] Can't unmarshal config!") } } diff --git a/config/types.go b/config/types.go index afdb35f..c582a18 100644 --- a/config/types.go +++ b/config/types.go @@ -63,6 +63,7 @@ type ttl struct { type kyc struct { AliAccessKeyId string `yaml:"ali_access_key_id"` AliAccessKeySecret string `yaml:"ali_access_key_secret"` + PassportReaderEndpoint string `yaml:"passport_reader_endpoint"` PassportReaderPublicKey string `yaml:"passport_reader_public_key"` PassportReaderSecret string `yaml:"passport_reader_secret"` } diff --git a/data/agenda_test.go b/data/agenda_test.go index 9a11f7a..15b57be 100644 --- a/data/agenda_test.go +++ b/data/agenda_test.go @@ -197,3 +197,37 @@ func TestAgendaDelete(t *testing.T) { require.NoError(t, err) assert.Nil(t, got) } + +func TestAgendaCountByEventId(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + evId, attId := seedAgendaFixture(t, ctx) + + for _, status := range []string{"pending", "approved", "rejected"} { + ag := data.NewAgenda( + data.WithAttendanceId(attId), + data.WithAgendaName(uuid.New().String()), + data.WithAgendaDescription("desc"), + data.WithAgendaStatus(status), + ) + require.NoError(t, ag.Create(ctx)) + } + + count, err := new(data.Agenda).CountByEventId(ctx, evId) + require.NoError(t, err) + assert.Equal(t, int64(3), count) +} + +func TestAgendaCountByEventIdEmpty(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev := makeEvent(owner) + require.NoError(t, ev.Create(ctx)) + + count, err := new(data.Agenda).CountByEventId(ctx, ev.EventId) + require.NoError(t, err) + assert.Equal(t, int64(0), count) +} diff --git a/data/attendance_test.go b/data/attendance_test.go index 3cfb967..488d800 100644 --- a/data/attendance_test.go +++ b/data/attendance_test.go @@ -2,6 +2,7 @@ package data_test import ( "context" + "fmt" "testing" "time" @@ -163,3 +164,318 @@ func TestAttendanceVerifyCheckinCodeInvalid(t *testing.T) { err := new(data.Attendance).VerifyCheckinCode(ctx, "000000") require.Error(t, err) } + +func TestAttendanceGetByAttendanceId(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + eventId, userId := seedEventAndUser(t, ctx) + a := seedAttendance(t, ctx, eventId, userId) + + got, err := new(data.Attendance).GetAttendanceByAttendanceId(ctx, a.AttendanceId) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, a.AttendanceId, got.AttendanceId) + assert.Equal(t, eventId, got.EventId) + assert.Equal(t, userId, got.UserId) +} + +func TestAttendanceGetByAttendanceIdNotFound(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + got, err := new(data.Attendance).GetAttendanceByAttendanceId(ctx, uuid.New()) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestAttendanceGetUsersByEventID(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev := makeEvent(owner) + require.NoError(t, ev.Create(ctx)) + + for i := 0; i < 3; i++ { + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + seedAttendance(t, ctx, ev.EventId, u.UserId) + } + + users, err := new(data.Attendance).GetUsersByEventID(ctx, ev.EventId) + require.NoError(t, err) + require.NotNil(t, users) + assert.Len(t, *users, 3) +} + +func TestAttendanceGetEventsByUserID(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev1 := makeEvent(owner) + require.NoError(t, ev1.Create(ctx)) + ev2 := makeEvent(owner) + require.NoError(t, ev2.Create(ctx)) + + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + seedAttendance(t, ctx, ev1.EventId, u.UserId) + seedAttendance(t, ctx, ev2.EventId, u.UserId) + + events, err := new(data.Attendance).GetEventsByUserID(ctx, u.UserId) + require.NoError(t, err) + require.NotNil(t, events) + assert.Len(t, *events, 2) +} + +func TestAttendanceGetAttendanceListByEventId(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev := makeEvent(owner) + require.NoError(t, ev.Create(ctx)) + + for i := 0; i < 4; i++ { + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + seedAttendance(t, ctx, ev.EventId, u.UserId) + } + + list, err := new(data.Attendance).GetAttendanceListByEventId(ctx, ev.EventId) + require.NoError(t, err) + require.NotNil(t, list) + assert.Len(t, *list, 4) +} + +func TestAttendanceGetCheckedInEventIDs(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev1 := makeEvent(owner) + require.NoError(t, ev1.Create(ctx)) + ev2 := makeEvent(owner) + require.NoError(t, ev2.Create(ctx)) + + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + + a1 := seedAttendance(t, ctx, ev1.EventId, u.UserId) + _, err := new(data.Attendance).PatchByAttendanceId(ctx, a1.AttendanceId, data.WithCheckinAt(time.Now())) + require.NoError(t, err) + + seedAttendance(t, ctx, ev2.EventId, u.UserId) + + checkedIn, err := new(data.Attendance).GetCheckedInEventIDs(ctx, u.UserId, []uuid.UUID{ev1.EventId, ev2.EventId}) + require.NoError(t, err) + assert.True(t, checkedIn[ev1.EventId]) + assert.False(t, checkedIn[ev2.EventId]) +} + +func TestAttendanceCountCheckedInUsersByEventID(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev := makeEvent(owner) + require.NoError(t, ev.Create(ctx)) + + for i := 0; i < 3; i++ { + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + a := seedAttendance(t, ctx, ev.EventId, u.UserId) + if i < 2 { + _, err := new(data.Attendance).PatchByAttendanceId(ctx, a.AttendanceId, data.WithCheckinAt(time.Now())) + require.NoError(t, err) + } + } + + count, err := new(data.Attendance).CountCheckedInUsersByEventID(ctx, ev.EventId) + require.NoError(t, err) + assert.Equal(t, int64(2), count) +} + +func TestAttendanceCountWithKycByEventID(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev := makeEvent(owner) + require.NoError(t, ev.Create(ctx)) + + for i := 0; i < 3; i++ { + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + kycId := uuid.Nil + if i < 2 { + kycId = uuid.New() + } + a := data.NewAttendance( + data.WithEventId(ev.EventId), + data.WithUserId(u.UserId), + data.WithKycId(kycId), + data.WithRole("attendee"), + data.WithState("success"), + ) + _, err := a.Create(ctx) + require.NoError(t, err) + } + + count, err := new(data.Attendance).CountWithKycByEventID(ctx, ev.EventId) + require.NoError(t, err) + assert.Equal(t, int64(2), count) +} + +func TestAttendanceGetAttendanceListFiltered(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev := makeEvent(owner) + require.NoError(t, ev.Create(ctx)) + + for i := 0; i < 5; i++ { + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithNickname(fmt.Sprintf("FilterUser%d", i)), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + kycId := uuid.Nil + if i < 3 { + kycId = uuid.New() + } + a := data.NewAttendance( + data.WithEventId(ev.EventId), + data.WithUserId(u.UserId), + data.WithKycId(kycId), + data.WithRole("attendee"), + data.WithState("success"), + ) + _, err := a.Create(ctx) + require.NoError(t, err) + } + + t.Run("no_filters_returns_all", func(t *testing.T) { + list, total, err := new(data.Attendance).GetAttendanceListFiltered(ctx, data.AttendanceListFilter{ + EventId: ev.EventId, + Limit: 20, + }) + require.NoError(t, err) + assert.Equal(t, int64(5), total) + assert.Len(t, *list, 5) + }) + + t.Run("with_kyc_filter", func(t *testing.T) { + list, total, err := new(data.Attendance).GetAttendanceListFiltered(ctx, data.AttendanceListFilter{ + EventId: ev.EventId, + KycStatus: "with_kyc", + Limit: 20, + }) + require.NoError(t, err) + assert.Equal(t, int64(3), total) + assert.Len(t, *list, 3) + }) + + t.Run("without_kyc_filter", func(t *testing.T) { + list, total, err := new(data.Attendance).GetAttendanceListFiltered(ctx, data.AttendanceListFilter{ + EventId: ev.EventId, + KycStatus: "without_kyc", + Limit: 20, + }) + require.NoError(t, err) + assert.Equal(t, int64(2), total) + assert.Len(t, *list, 2) + }) + + t.Run("name_filter", func(t *testing.T) { + list, total, err := new(data.Attendance).GetAttendanceListFiltered(ctx, data.AttendanceListFilter{ + EventId: ev.EventId, + Name: "FilterUser1", + Limit: 20, + }) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Len(t, *list, 1) + }) + + t.Run("pagination", func(t *testing.T) { + list, total, err := new(data.Attendance).GetAttendanceListFiltered(ctx, data.AttendanceListFilter{ + EventId: ev.EventId, + Limit: 2, + Offset: 0, + }) + require.NoError(t, err) + assert.Equal(t, int64(5), total) + assert.Len(t, *list, 2) + }) + + t.Run("sort_by_id_asc", func(t *testing.T) { + list, _, err := new(data.Attendance).GetAttendanceListFiltered(ctx, data.AttendanceListFilter{ + EventId: ev.EventId, + SortBy: "id", + SortOrder: "asc", + Limit: 20, + }) + require.NoError(t, err) + require.NotNil(t, list) + items := *list + for i := 1; i < len(items); i++ { + assert.GreaterOrEqual(t, items[i].Id, items[i-1].Id) + } + }) +} + +func TestAttendanceCheckinCodeExpiry(t *testing.T) { + mr := testutil.Setup(t) + ctx := context.Background() + + eventId, userId := seedEventAndUser(t, ctx) + a := seedAttendance(t, ctx, eventId, userId) + + code, err := a.GenCheckinCode(ctx, a.EventId) + require.NoError(t, err) + require.NotNil(t, code) + + mr.FastForward(10 * time.Minute) + + err = new(data.Attendance).VerifyCheckinCode(ctx, *code) + require.Error(t, err, "expired code should not be valid") +} + +func TestAttendanceGetCheckedInEventIDsEmpty(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + result, err := new(data.Attendance).GetCheckedInEventIDs(ctx, uuid.New(), []uuid.UUID{}) + require.NoError(t, err) + assert.Empty(t, result) +} diff --git a/data/event_test.go b/data/event_test.go index 42542ca..11ad55f 100644 --- a/data/event_test.go +++ b/data/event_test.go @@ -156,3 +156,50 @@ func TestEventFastList(t *testing.T) { require.NoError(t, err) assert.Len(t, *results, 3) } + +func TestEventGetEventsByUserId(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev1 := makeEvent(owner) + require.NoError(t, ev1.Create(ctx)) + ev2 := makeEvent(owner) + require.NoError(t, ev2.Create(ctx)) + ev3 := makeEvent(owner) + require.NoError(t, ev3.Create(ctx)) + + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + + for _, evId := range []uuid.UUID{ev1.EventId, ev2.EventId} { + a := data.NewAttendance( + data.WithEventId(evId), + data.WithUserId(u.UserId), + data.WithKycId(uuid.Nil), + data.WithRole("attendee"), + data.WithState("success"), + ) + _, err := a.Create(ctx) + require.NoError(t, err) + } + + results, err := new(data.Event).GetEventsByUserId(ctx, u.UserId, 10, 0) + require.NoError(t, err) + require.NotNil(t, results) + assert.Len(t, *results, 2) +} + +func TestEventGetEventsByUserIdEmpty(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + results, err := new(data.Event).GetEventsByUserId(ctx, uuid.New(), 10, 0) + require.NoError(t, err) + require.NotNil(t, results) + assert.Empty(t, *results) +} diff --git a/data/stats_test.go b/data/stats_test.go index 2a5e679..98a8755 100644 --- a/data/stats_test.go +++ b/data/stats_test.go @@ -96,3 +96,51 @@ func TestGlobalStatsEventJoinCheckinCounts(t *testing.T) { assert.Equal(t, int64(2), stat.JoinCount) assert.Equal(t, int64(1), stat.CheckinCount) } + +func TestGlobalStatsEventJoinCheckinCountsEmpty(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + results, err := new(data.GlobalStats).EventJoinCheckinCounts(ctx) + require.NoError(t, err) + require.NotNil(t, results) + assert.Empty(t, *results) +} + +func TestGlobalStatsEventJoinCheckinMultipleEvents(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := uuid.New() + ev1 := makeEvent(owner) + require.NoError(t, ev1.Create(ctx)) + ev2 := makeEvent(owner) + require.NoError(t, ev2.Create(ctx)) + + for _, evId := range []uuid.UUID{ev1.EventId, ev2.EventId} { + u := data.NewUser( + data.WithEmail(uuid.New().String()+"@test.com"), + data.WithUsername(uuid.New().String()), + data.WithPermissionLevel(10), + ) + require.NoError(t, u.Create(ctx)) + a := data.NewAttendance( + data.WithEventId(evId), + data.WithUserId(u.UserId), + data.WithKycId(uuid.Nil), + data.WithRole("attendee"), + data.WithState("success"), + ) + _, err := a.Create(ctx) + require.NoError(t, err) + } + + results, err := new(data.GlobalStats).EventJoinCheckinCounts(ctx) + require.NoError(t, err) + require.NotNil(t, results) + assert.Len(t, *results, 2) + for _, stat := range *results { + assert.Equal(t, int64(1), stat.JoinCount) + assert.Equal(t, int64(0), stat.CheckinCount) + } +} diff --git a/internal/exception/builder_test.go b/internal/exception/builder_test.go index 7813682..94a310e 100644 --- a/internal/exception/builder_test.go +++ b/internal/exception/builder_test.go @@ -101,3 +101,35 @@ func TestBuilderErrorCodeLength(t *testing.T) { // Status(1) + Endpoint(3) + Service(3) + Type(1) + Original(5) = 13 assert.Len(t, b.ErrorCode, 13) } + +func TestErrorHandlerDoesNotPanic(t *testing.T) { + ctx := context.Background() + testErr := errors.New("test error") + + for _, status := range []string{StatusSuccess, StatusUser, StatusServer, StatusClient} { + s := status + assert.NotPanics(t, func() { + ErrorHandler(ctx, s, "code12345678", testErr) + }, "ErrorHandler must not panic for status %q", s) + } +} + +func TestErrorHandlerNilError(t *testing.T) { + ctx := context.Background() + assert.NotPanics(t, func() { + ErrorHandler(ctx, StatusUser, "code12345678", nil) + }) +} + +func TestBuilderNilThrow(t *testing.T) { + b := New( + WithStatus(StatusSuccess), + WithType(TypeCommon), + WithOriginal(CommonSuccess), + ) + // Throw on empty context (no service/endpoint set) must not panic + assert.NotPanics(t, func() { + thrown := b.Throw(context.Background()) + assert.Equal(t, CommonSuccess, thrown.Original) + }) +} diff --git a/internal/kyc/passport.go b/internal/kyc/passport.go index 69ad23a..ef5941f 100644 --- a/internal/kyc/passport.go +++ b/internal/kyc/passport.go @@ -23,9 +23,9 @@ const ( ) func doPassportRequest(ctx context.Context, method, path string, body any, target any) error { + baseURL := viper.GetString("kyc.passport_reader_endpoint") publicKey := viper.GetString("kyc.passport_reader_public_key") secret := viper.GetString("kyc.passport_reader_secret") - baseURL := "https://passportreader.app/api/v1" var bodyReader io.Reader if body != nil { diff --git a/internal/kyc/passport_test.go b/internal/kyc/passport_test.go index 0795b45..f15877b 100644 --- a/internal/kyc/passport_test.go +++ b/internal/kyc/passport_test.go @@ -2,41 +2,190 @@ package kyc import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/spf13/viper" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -// passport.go hardcodes baseURL = "https://passportreader.app/api/v1" with no -// injection point, so we cannot intercept the HTTP calls in tests. These tests -// verify that each function returns an error when the remote server is -// unreachable (no valid credentials in the test environment). - -func setupPassportViper(t *testing.T) { +// startMockPassportServer spins up an httptest.Server backed by mux, points +// viper's kyc.passport_reader_endpoint at it, and registers cleanup on t. +func startMockPassportServer(t *testing.T, mux *http.ServeMux) *httptest.Server { t.Helper() - viper.Set("kyc.passport_reader_public_key", "test-pub-key") - viper.Set("kyc.passport_reader_secret", "test-secret") - t.Cleanup(func() { viper.Reset() }) + srv := httptest.NewServer(mux) + viper.Reset() + viper.Set("kyc.passport_reader_endpoint", srv.URL) + viper.Set("kyc.passport_reader_public_key", "pub") + viper.Set("kyc.passport_reader_secret", "sec") + t.Cleanup(func() { + srv.Close() + viper.Reset() + }) + return srv } -func TestCreateSessionReturnsErrorWithoutRealServer(t *testing.T) { - setupPassportViper(t) +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// ---- CreateSession ---- + +func TestCreateSessionSuccess(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.create", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + writeJSON(w, PassportReaderSessionResponse{ID: 42, Token: "tok"}) + }) + startMockPassportServer(t, mux) + + resp, err := CreateSession(context.Background()) + require.NoError(t, err) + assert.Equal(t, 42, resp.ID) + assert.Equal(t, "tok", resp.Token) +} + +func TestCreateSessionAPIError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.create", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) + }) + startMockPassportServer(t, mux) _, err := CreateSession(context.Background()) - assert.Error(t, err, "CreateSession must fail when the remote server is unreachable") + assert.Error(t, err) } -func TestGetSessionStateReturnsErrorWithoutRealServer(t *testing.T) { - setupPassportViper(t) +// ---- GetSessionState ---- - _, err := GetSessionState(context.Background(), 99999) - assert.Error(t, err, "GetSessionState must fail when the remote server is unreachable") +func TestGetSessionStateSuccess(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + var req PassportReaderGetSessionRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Equal(t, 42, req.ID) + writeJSON(w, PassportReaderStateResponse{State: StateApproved}) + }) + startMockPassportServer(t, mux) + + state, err := GetSessionState(context.Background(), 42) + require.NoError(t, err) + assert.Equal(t, StateApproved, state) } -func TestGetSessionDetailsReturnsErrorWithoutRealServer(t *testing.T) { - setupPassportViper(t) +func TestGetSessionStateEachKnownState(t *testing.T) { + knownStates := []string{ + StateCreated, StateInitiated, StateCompleted, + StateApproved, StateFailed, StateAborted, StateRejected, + } - _, err := GetSessionDetails(context.Background(), 99999) - assert.Error(t, err, "GetSessionDetails must fail when the remote server is unreachable") + for _, s := range knownStates { + s := s + t.Run(s, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, PassportReaderStateResponse{State: s}) + }) + startMockPassportServer(t, mux) + + got, err := GetSessionState(context.Background(), 1) + require.NoError(t, err) + assert.Equal(t, s, got) + }) + } +} + +func TestGetSessionStateAPIError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "bad", http.StatusBadRequest) + }) + startMockPassportServer(t, mux) + + _, err := GetSessionState(context.Background(), 1) + assert.Error(t, err) +} + +// ---- GetSessionDetails ---- + +func TestGetSessionDetailsSuccess(t *testing.T) { + want := &PassportReaderSessionDetailResponse{ + State: StateApproved, + GivenNames: "John", + Surname: "Doe", + Nationality: "USA", + DateOfBirth: "1990-01-01", + DocumentType: "PASSPORT", + DocumentNumber: "X12345678", + ExpiryDate: "2030-01-01", + } + mux := http.NewServeMux() + mux.HandleFunc("/session.get", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + var req PassportReaderGetSessionRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Equal(t, 7, req.ID) + writeJSON(w, want) + }) + startMockPassportServer(t, mux) + + got, err := GetSessionDetails(context.Background(), 7) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestGetSessionDetailsAPIError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.get", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + }) + startMockPassportServer(t, mux) + + _, err := GetSessionDetails(context.Background(), 1) + assert.Error(t, err) +} + +// ---- HTTP mechanics ---- + +func TestPassportRequestSendsBasicAuth(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.create", func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + assert.True(t, ok, "Authorization header must be present") + assert.Equal(t, "pub", user) + assert.Equal(t, "sec", pass) + writeJSON(w, PassportReaderSessionResponse{}) + }) + startMockPassportServer(t, mux) + + _, _ = CreateSession(context.Background()) +} + +func TestPassportRequestSetsContentTypeForBody(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + writeJSON(w, PassportReaderStateResponse{State: StateCreated}) + }) + startMockPassportServer(t, mux) + + _, _ = GetSessionState(context.Background(), 1) +} + +func TestPassportRequestNoContentTypeWithoutBody(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/session.create", func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Content-Type"), "no Content-Type when body is nil") + writeJSON(w, PassportReaderSessionResponse{}) + }) + startMockPassportServer(t, mux) + + _, _ = CreateSession(context.Background()) } diff --git a/justfile b/justfile index f2e12e6..639d10e 100644 --- a/justfile +++ b/justfile @@ -23,7 +23,7 @@ run: cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ server_exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }} test: - cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. ./... + cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. -p 4 ./... watch: watchexec -r -e go,yaml,tpl -i '.devenv/**' -i '.direnv/**' -i 'client/**' -i 'vendor/**' 'go build -o {{ server_exec_path }} . && cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ server_exec_path }}' diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go index f9490e5..377e72f 100644 --- a/middleware/middleware_test.go +++ b/middleware/middleware_test.go @@ -225,3 +225,60 @@ func TestPermissionFromDBInsufficient(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, 403, w.Code) } + +func TestPermissionUserNotFound(t *testing.T) { + testutil.Setup(t) + + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user_id", uuid.New().String()) + c.Next() + }) + r.Use(Permission(0)) + r.GET("/any", func(c *gin.Context) { c.String(200, "ok") }) + + req := httptest.NewRequest(http.MethodGet, "/any", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, 404, w.Code) +} + +func TestPermissionCachesLevelFromDB(t *testing.T) { + testutil.Setup(t) + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + + calls := 0 + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user_id", user.UserId.String()) + calls++ + c.Next() + }) + r.Use(Permission(5)) + r.GET("/any", func(c *gin.Context) { + lvl, ok := c.Get("permission_level") + assert.True(t, ok) + assert.Equal(t, uint(10), lvl.(uint)) + c.String(200, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/any", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) +} + +func TestJWTAuthBadToken(t *testing.T) { + testutil.Setup(t) + testutil.SeedClient(t) + + r := gin.New() + r.Use(JWTAuth()) + r.GET("/protected", func(c *gin.Context) { c.String(200, "ok") }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer completely.invalid.token") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, 401, w.Code) +} diff --git a/server/server_test.go b/server/server_test.go index 1c06c29..edde417 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -112,7 +112,8 @@ func TestServerHealthEndpoints(t *testing.T) { // Response must be valid JSON. var body map[string]any - _ = json.Unmarshal(w.Body.Bytes(), &body) + err := json.Unmarshal(w.Body.Bytes(), &body) + assert.NoError(t, err, "response must be valid JSON for %s %s", ep.method, ep.path) }) } } diff --git a/service/service_agenda/agenda_test.go b/service/service_agenda/agenda_test.go index 49ca0c3..d7e63eb 100644 --- a/service/service_agenda/agenda_test.go +++ b/service/service_agenda/agenda_test.go @@ -236,6 +236,40 @@ func TestAgendaReviewReject(t *testing.T) { assert.Equal(t, 200, result.Common.HttpCode) } +func TestAgendaReviewInvalidStatus(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + event, user, _ := seedAgendaTestFixture(t, ctx) + svc := newAgendaSvc() + + submitResult := svc.Submit(&SubmitPayload{ + Context: ctx, + UserId: user.UserId, + Data: &SubmitData{EventId: event.EventId, Name: "Talk"}, + }) + require.Equal(t, 200, submitResult.Common.HttpCode) + + manager := testutil.SeedUser(t, testutil.RandomEmail(), 30) + + result := svc.Review(&AgendaReviewPayload{ + Context: ctx, + UserId: manager.UserId, + Data: &AgendaReviewData{ + EventId: event.EventId, + AgendaId: submitResult.Data.AgendaId, + Status: "banana", + }, + }) + + assert.Equal(t, 400, result.Common.HttpCode) + + // Verify the agenda status was not mutated in the database. + ag, err := new(data.Agenda).GetByAgendaId(ctx, submitResult.Data.AgendaId) + require.NoError(t, err) + assert.Equal(t, "pending", ag.Status) +} + // ---- List ---- func TestAgendaList(t *testing.T) { @@ -313,6 +347,215 @@ func TestAgendaScheduleGetNotPublished(t *testing.T) { assert.Equal(t, exception.AgendaScheduleGetNotPublished, result.Common.Exception.Original) } +// ---- Update ---- + +func TestAgendaUpdateSubmitterSuccess(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + event, user, _ := seedAgendaTestFixture(t, ctx) + svc := newAgendaSvc() + + submitResult := svc.Submit(&SubmitPayload{ + Context: ctx, + UserId: user.UserId, + Data: &SubmitData{EventId: event.EventId, Name: "Original Name"}, + }) + require.Equal(t, 200, submitResult.Common.HttpCode) + + newName := "Updated Name" + result := svc.Update(&AgendaUpdatePayload{ + Context: ctx, + UserId: user.UserId, + Data: &AgendaUpdateData{ + AgendaId: submitResult.Data.AgendaId, + Name: &newName, + PermissionLevel: 10, + }, + }) + + assert.Equal(t, 200, result.Common.HttpCode) + + ag, err := new(data.Agenda).GetByAgendaId(ctx, submitResult.Data.AgendaId) + require.NoError(t, err) + assert.Equal(t, "Updated Name", ag.Name) +} + +func TestAgendaUpdateManagerCanEdit(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + event, user, _ := seedAgendaTestFixture(t, ctx) + svc := newAgendaSvc() + + submitResult := svc.Submit(&SubmitPayload{ + Context: ctx, + UserId: user.UserId, + Data: &SubmitData{EventId: event.EventId, Name: "Original Name"}, + }) + require.Equal(t, 200, submitResult.Common.HttpCode) + + manager := testutil.SeedUser(t, testutil.RandomEmail(), 30) + newName := "Manager Updated" + result := svc.Update(&AgendaUpdatePayload{ + Context: ctx, + UserId: manager.UserId, + Data: &AgendaUpdateData{ + AgendaId: submitResult.Data.AgendaId, + Name: &newName, + PermissionLevel: 30, + }, + }) + + assert.Equal(t, 200, result.Common.HttpCode) + + ag, err := new(data.Agenda).GetByAgendaId(ctx, submitResult.Data.AgendaId) + require.NoError(t, err) + assert.Equal(t, "Manager Updated", ag.Name) +} + +func TestAgendaUpdateNotSubmitter(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + event, user, _ := seedAgendaTestFixture(t, ctx) + svc := newAgendaSvc() + + submitResult := svc.Submit(&SubmitPayload{ + Context: ctx, + UserId: user.UserId, + Data: &SubmitData{EventId: event.EventId, Name: "My Talk"}, + }) + require.Equal(t, 200, submitResult.Common.HttpCode) + + other := testutil.SeedUser(t, testutil.RandomEmail(), 10) + newName := "Hijacked" + result := svc.Update(&AgendaUpdatePayload{ + Context: ctx, + UserId: other.UserId, + Data: &AgendaUpdateData{ + AgendaId: submitResult.Data.AgendaId, + Name: &newName, + PermissionLevel: 10, + }, + }) + + assert.Equal(t, 403, result.Common.HttpCode) + assert.Equal(t, exception.AgendaUpdateNotSubmitter, result.Common.Exception.Original) +} + +func TestAgendaUpdateNotPending(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + event, user, _ := seedAgendaTestFixture(t, ctx) + svc := newAgendaSvc() + + submitResult := svc.Submit(&SubmitPayload{ + Context: ctx, + UserId: user.UserId, + Data: &SubmitData{EventId: event.EventId, Name: "My Talk"}, + }) + require.Equal(t, 200, submitResult.Common.HttpCode) + + manager := testutil.SeedUser(t, testutil.RandomEmail(), 30) + svc.Review(&AgendaReviewPayload{ + Context: ctx, + UserId: manager.UserId, + Data: &AgendaReviewData{ + EventId: event.EventId, + AgendaId: submitResult.Data.AgendaId, + Status: "approved", + }, + }) + + newName := "Try to Edit" + result := svc.Update(&AgendaUpdatePayload{ + Context: ctx, + UserId: user.UserId, + Data: &AgendaUpdateData{ + AgendaId: submitResult.Data.AgendaId, + Name: &newName, + PermissionLevel: 10, + }, + }) + + assert.Equal(t, 400, result.Common.HttpCode) + assert.Equal(t, exception.AgendaUpdateNotPending, result.Common.Exception.Original) +} + +func TestAgendaUpdateDeadlinePassed(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + user := testutil.SeedUser(t, testutil.RandomEmail(), 10) + require.NoError(t, new(data.User).PatchByUserId(ctx, user.UserId, data.WithNickname("Attendee"))) + + owner := testutil.SeedUser(t, testutil.RandomEmail(), 40) + event := data.NewEvent( + data.WithOwner(owner.UserId), + data.WithEventType("party"), + data.WithEventName("Past Event"), + data.WithEventSubtitle("sub"), + data.WithEventStartTime(time.Now().Add(-2*time.Hour)), + data.WithEventEndTime(time.Now().Add(-time.Hour)), + data.WithQuota(100), + data.WithLimit(150), + ) + require.NoError(t, event.Create(ctx)) + + att := data.NewAttendance( + data.WithEventId(event.EventId), + data.WithUserId(user.UserId), + data.WithKycId(uuid.Nil), + data.WithRole("attendee"), + data.WithState("success"), + ) + _, err := att.Create(ctx) + require.NoError(t, err) + + ag := data.NewAgenda( + data.WithAttendanceId(att.AttendanceId), + data.WithAgendaName("Past Talk"), + data.WithAgendaDescription("desc"), + data.WithAgendaStatus("pending"), + ) + require.NoError(t, ag.Create(ctx)) + + newName := "Updated Past" + svc := newAgendaSvc() + result := svc.Update(&AgendaUpdatePayload{ + Context: ctx, + UserId: user.UserId, + Data: &AgendaUpdateData{ + AgendaId: ag.AgendaId, + Name: &newName, + PermissionLevel: 10, + }, + }) + + assert.Equal(t, 400, result.Common.HttpCode) + assert.Equal(t, exception.AgendaUpdateDeadlinePassed, result.Common.Exception.Original) +} + +func TestAgendaUpdateNotFound(t *testing.T) { + testutil.Setup(t) + + svc := newAgendaSvc() + newName := "Phantom" + result := svc.Update(&AgendaUpdatePayload{ + Context: context.Background(), + UserId: uuid.New(), + Data: &AgendaUpdateData{ + AgendaId: uuid.New(), + Name: &newName, + PermissionLevel: 10, + }, + }) + + assert.Equal(t, 404, result.Common.HttpCode) +} + func TestAgendaScheduleGetPublished(t *testing.T) { testutil.Setup(t) ctx := context.Background() diff --git a/service/service_agenda/review.go b/service/service_agenda/review.go index b0befdc..176432e 100644 --- a/service/service_agenda/review.go +++ b/service/service_agenda/review.go @@ -109,6 +109,20 @@ func (self *AgendaServiceImpl) Review(payload *AgendaReviewPayload) (result *Age return } + if payload.Data.Status != "approved" && payload.Data.Status != "rejected" { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("status must be approved or rejected")), + ).Throw(ctx) + + result = &AgendaReviewResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + if err := new(data.Agenda).PatchByAgendaId(ctx, payload.Data.AgendaId, data.WithAgendaStatus(payload.Data.Status), ); err != nil { diff --git a/service/service_event/event_test.go b/service/service_event/event_test.go index f8d40b5..acd7ec4 100644 --- a/service/service_event/event_test.go +++ b/service/service_event/event_test.go @@ -13,6 +13,7 @@ import ( "nixcn-cms/testutil" ) + func newEventSvc() EventService { return NewEventService() } func makeOwner(t *testing.T, permLevel uint) *data.User { @@ -431,3 +432,311 @@ func TestEventStatsNotOwner(t *testing.T) { assert.Equal(t, 403, result.Common.HttpCode) assert.Equal(t, exception.EventStatsNotOwner, result.Common.Exception.Original) } + +// ---- Checkin ---- + +func TestEventCheckinSuccess(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := makeOwner(t, 40) + joiner := makeOwner(t, 10) + require.NoError(t, new(data.User).PatchByUserId(ctx, joiner.UserId, data.WithNickname("Joiner"))) + + cr := createEventForOwner(t, ctx, owner, "party") + require.Equal(t, 200, cr.Common.HttpCode) + + svc := newEventSvc() + jr := svc.Join(&EventJoinPayload{ + Context: ctx, + Data: &EventJoinData{ + EventId: cr.Data.EventId, + UserId: joiner.UserId.String(), + }, + }) + require.Equal(t, 200, jr.Common.HttpCode) + + eventId, _ := uuid.Parse(cr.Data.EventId) + result := svc.Checkin(&CheckinPayload{ + Context: ctx, + UserId: joiner.UserId, + Data: &CheckinData{EventId: eventId}, + }) + + assert.Equal(t, 200, result.Common.HttpCode) + require.NotNil(t, result.Data) + require.NotNil(t, result.Data.CheckinCode) + assert.Len(t, *result.Data.CheckinCode, 6) +} + +func TestEventCheckinNotAttendee(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := makeOwner(t, 40) + notJoiner := makeOwner(t, 10) + + cr := createEventForOwner(t, ctx, owner, "party") + require.Equal(t, 200, cr.Common.HttpCode) + + eventId, _ := uuid.Parse(cr.Data.EventId) + svc := newEventSvc() + result := svc.Checkin(&CheckinPayload{ + Context: ctx, + UserId: notJoiner.UserId, + Data: &CheckinData{EventId: eventId}, + }) + + assert.Equal(t, 403, result.Common.HttpCode) +} + +// ---- CheckinSubmit ---- + +func TestEventCheckinSubmitSuccess(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := makeOwner(t, 40) + joiner := makeOwner(t, 10) + require.NoError(t, new(data.User).PatchByUserId(ctx, joiner.UserId, data.WithNickname("Joiner"))) + + cr := createEventForOwner(t, ctx, owner, "party") + require.Equal(t, 200, cr.Common.HttpCode) + + svc := newEventSvc() + jr := svc.Join(&EventJoinPayload{ + Context: ctx, + Data: &EventJoinData{ + EventId: cr.Data.EventId, + UserId: joiner.UserId.String(), + }, + }) + require.Equal(t, 200, jr.Common.HttpCode) + + eventId, _ := uuid.Parse(cr.Data.EventId) + checkinResult := svc.Checkin(&CheckinPayload{ + Context: ctx, + UserId: joiner.UserId, + Data: &CheckinData{EventId: eventId}, + }) + require.Equal(t, 200, checkinResult.Common.HttpCode) + + submitResult := svc.CheckinSubmit(&CheckinSubmitPayload{ + Context: ctx, + Data: &CheckinSubmitData{CheckinCode: *checkinResult.Data.CheckinCode}, + }) + assert.Equal(t, 200, submitResult.Common.HttpCode) +} + +func TestEventCheckinSubmitInvalidCode(t *testing.T) { + testutil.Setup(t) + + svc := newEventSvc() + result := svc.CheckinSubmit(&CheckinSubmitPayload{ + Context: context.Background(), + Data: &CheckinSubmitData{CheckinCode: "000000"}, + }) + + assert.Equal(t, 400, result.Common.HttpCode) +} + +// ---- CheckinQuery ---- + +func TestEventCheckinQueryNotCheckedIn(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := makeOwner(t, 40) + joiner := makeOwner(t, 10) + require.NoError(t, new(data.User).PatchByUserId(ctx, joiner.UserId, data.WithNickname("Joiner"))) + + cr := createEventForOwner(t, ctx, owner, "party") + require.Equal(t, 200, cr.Common.HttpCode) + + svc := newEventSvc() + jr := svc.Join(&EventJoinPayload{ + Context: ctx, + Data: &EventJoinData{ + EventId: cr.Data.EventId, + UserId: joiner.UserId.String(), + }, + }) + require.Equal(t, 200, jr.Common.HttpCode) + + eventId, _ := uuid.Parse(cr.Data.EventId) + result := svc.CheckinQuery(&CheckinQueryPayload{ + Context: ctx, + UserId: joiner.UserId, + Data: &CheckinQueryData{EventId: eventId}, + }) + + assert.Equal(t, 200, result.Common.HttpCode) + require.NotNil(t, result.Data) + assert.Nil(t, result.Data.CheckinAt) +} + +func TestEventCheckinQueryNotFound(t *testing.T) { + testutil.Setup(t) + + svc := newEventSvc() + result := svc.CheckinQuery(&CheckinQueryPayload{ + Context: context.Background(), + UserId: uuid.New(), + Data: &CheckinQueryData{EventId: uuid.New()}, + }) + + assert.Equal(t, 404, result.Common.HttpCode) +} + +// ---- AttendanceList ---- + +func TestEventAttendanceListSuccess(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := makeOwner(t, 40) + cr := createEventForOwner(t, ctx, owner, "party") + require.Equal(t, 200, cr.Common.HttpCode) + eventId, _ := uuid.Parse(cr.Data.EventId) + + svc := newEventSvc() + for i := 0; i < 2; i++ { + joiner := makeOwner(t, 10) + require.NoError(t, new(data.User).PatchByUserId(ctx, joiner.UserId, data.WithNickname("Joiner"))) + jr := svc.Join(&EventJoinPayload{ + Context: ctx, + Data: &EventJoinData{ + EventId: cr.Data.EventId, + UserId: joiner.UserId.String(), + }, + }) + require.Equal(t, 200, jr.Common.HttpCode) + } + + limit := "10" + offset := "0" + result := svc.AttendanceList(&AttendanceListPayload{ + Context: ctx, + UserId: owner.UserId, + Data: &AttendanceListData{ + EventId: eventId, + Limit: &limit, + Offset: &offset, + }, + }) + + assert.Equal(t, 200, result.Common.HttpCode) + require.NotNil(t, result.Data) + assert.Equal(t, int64(2), result.Data.Total) + assert.Len(t, result.Data.Items, 2) +} + +func TestEventAttendanceListNotOwner(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := makeOwner(t, 40) + notOwner := makeOwner(t, 40) + cr := createEventForOwner(t, ctx, owner, "party") + require.Equal(t, 200, cr.Common.HttpCode) + eventId, _ := uuid.Parse(cr.Data.EventId) + + svc := newEventSvc() + result := svc.AttendanceList(&AttendanceListPayload{ + Context: ctx, + UserId: notOwner.UserId, + Data: &AttendanceListData{EventId: eventId}, + }) + + assert.Equal(t, 403, result.Common.HttpCode) +} + +func TestEventAttendanceListNotFound(t *testing.T) { + testutil.Setup(t) + + svc := newEventSvc() + result := svc.AttendanceList(&AttendanceListPayload{ + Context: context.Background(), + UserId: uuid.New(), + Data: &AttendanceListData{EventId: uuid.New()}, + }) + + assert.Equal(t, 404, result.Common.HttpCode) +} + +// ---- GetAttendanceGuide ---- + +func TestEventGetAttendanceGuideSuccess(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := makeOwner(t, 40) + joiner := makeOwner(t, 10) + require.NoError(t, new(data.User).PatchByUserId(ctx, joiner.UserId, data.WithNickname("Joiner"))) + + cr := createEventForOwner(t, ctx, owner, "party") + require.Equal(t, 200, cr.Common.HttpCode) + eventId, _ := uuid.Parse(cr.Data.EventId) + + require.NoError(t, new(data.Event).PatchByEventId(ctx, eventId, data.WithAttendanceGuide("base64guidetext"))) + + svc := newEventSvc() + jr := svc.Join(&EventJoinPayload{ + Context: ctx, + Data: &EventJoinData{ + EventId: cr.Data.EventId, + UserId: joiner.UserId.String(), + }, + }) + require.Equal(t, 200, jr.Common.HttpCode) + + result := svc.GetAttendanceGuide(&AttendanceGuidePayload{ + Context: ctx, + UserId: joiner.UserId, + Data: &AttendanceGuideData{EventId: eventId}, + }) + + assert.Equal(t, 200, result.Common.HttpCode) + require.NotNil(t, result.Data) + assert.Equal(t, "base64guidetext", result.Data.AttendanceGuide) +} + +// ---- Join edge cases ---- + +func TestEventJoinLimitExceeded(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + owner := makeOwner(t, 40) + svc := newEventSvc() + + cr := svc.Create(&EventCreatePayload{ + Context: ctx, + Data: &EventCreateData{ + UserId: owner.UserId.String(), + PermissionLevel: owner.PermissionLevel, + Type: "party", + Name: "Zero Capacity Event", + Subtitle: "sub", + StartTime: time.Now().Add(time.Hour), + EndTime: time.Now().Add(2 * time.Hour), + Quota: 0, + Limit: 0, + }, + }) + require.Equal(t, 200, cr.Common.HttpCode) + + joiner := makeOwner(t, 10) + require.NoError(t, new(data.User).PatchByUserId(ctx, joiner.UserId, data.WithNickname("Joiner"))) + + result := svc.Join(&EventJoinPayload{ + Context: ctx, + Data: &EventJoinData{ + EventId: cr.Data.EventId, + UserId: joiner.UserId.String(), + }, + }) + + assert.Equal(t, 403, result.Common.HttpCode) + assert.Equal(t, exception.EventJoinLimitExceeded, result.Common.Exception.Original) +} diff --git a/service/service_kyc/kyc_test.go b/service/service_kyc/kyc_test.go index 626801a..b8f77e5 100644 --- a/service/service_kyc/kyc_test.go +++ b/service/service_kyc/kyc_test.go @@ -3,9 +3,14 @@ package service_kyc import ( "context" "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" "testing" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "nixcn-cms/testutil" ) @@ -87,14 +92,97 @@ func TestSessionKycInvalidType(t *testing.T) { func TestQueryKycExternalAPIError(t *testing.T) { testutil.Setup(t) - // PassportReaderSessionId is 0 (zero value), calling GetSessionState will - // fail with a network/API error since there is no real PassportReader server. + // kyc.passport_reader_endpoint is unset, so GetSessionState will fail. svc := newKycSvc() result := svc.QueryKyc(&KycQueryPayload{ Context: context.Background(), Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"}, }) - // External API is unavailable in test → expect a 400 error response assert.Equal(t, 400, result.Common.HttpCode) } + +func TestQueryKycUnknownSessionState(t *testing.T) { + testutil.Setup(t) + + // Start a mock server that returns a state string matching no known branch. + mux := http.NewServeMux() + mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"state": "UNKNOWN_STATE_XYZ"}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + viper.Set("kyc.passport_reader_endpoint", srv.URL) + + svc := &KycServiceImpl{PassportReaderSessionId: 1} + result := svc.QueryKyc(&KycQueryPayload{ + Context: context.Background(), + Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"}, + }) + + require.NotNil(t, result, "result must never be nil") + assert.Equal(t, 500, result.Common.HttpCode) +} + +func TestQueryKycPendingStates(t *testing.T) { + pendingStates := []string{"CREATED", "INITIATED", "COMPLETED"} + + for _, s := range pendingStates { + s := s + t.Run(s, func(t *testing.T) { + testutil.Setup(t) + + mux := http.NewServeMux() + mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"state": s}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + viper.Set("kyc.passport_reader_endpoint", srv.URL) + + svc := &KycServiceImpl{PassportReaderSessionId: 1} + result := svc.QueryKyc(&KycQueryPayload{ + Context: context.Background(), + Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"}, + }) + + require.NotNil(t, result) + assert.Equal(t, 200, result.Common.HttpCode) + require.NotNil(t, result.Data) + assert.Equal(t, "pending", result.Data.Status) + }) + } +} + +func TestQueryKycFailedStates(t *testing.T) { + failedStates := []string{"FAILED", "ABORTED", "REJECTED"} + + for _, s := range failedStates { + s := s + t.Run(s, func(t *testing.T) { + testutil.Setup(t) + + mux := http.NewServeMux() + mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"state": s}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + viper.Set("kyc.passport_reader_endpoint", srv.URL) + + svc := &KycServiceImpl{PassportReaderSessionId: 1} + result := svc.QueryKyc(&KycQueryPayload{ + Context: context.Background(), + Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"}, + }) + + require.NotNil(t, result) + assert.Equal(t, 200, result.Common.HttpCode) + require.NotNil(t, result.Data) + assert.Equal(t, "failed", result.Data.Status) + }) + } +} diff --git a/service/service_kyc/query.go b/service/service_kyc/query.go index 6a5d4d9..80d2e63 100644 --- a/service/service_kyc/query.go +++ b/service/service_kyc/query.go @@ -2,6 +2,7 @@ package service_kyc import ( "context" + "errors" "nixcn-cms/data" "nixcn-cms/internal/exception" "nixcn-cms/internal/kyc" @@ -229,5 +230,18 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery return } + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("unknown session state: "+string(sessionState))), + ).Throw(ctx) + + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 500, + Exception: exc, + }, + } return } diff --git a/service/service_stats/stats_test.go b/service/service_stats/stats_test.go index 439288a..e4193b6 100644 --- a/service/service_stats/stats_test.go +++ b/service/service_stats/stats_test.go @@ -49,3 +49,16 @@ func TestGlobalStatsWithData(t *testing.T) { assert.Equal(t, int64(2), countMap[10]) assert.Equal(t, int64(1), countMap[30]) } + +func TestGlobalStatsEventJoinCheckinField(t *testing.T) { + testutil.Setup(t) + ctx := context.Background() + + svc := NewStatsService() + result := svc.Global(&GlobalStatsPayload{Context: ctx}) + + assert.Equal(t, 200, result.Common.HttpCode) + require.NotNil(t, result.Data) + require.NotNil(t, result.Data.EventJoinCheckin, "EventJoinCheckin field must not be nil") + assert.Empty(t, *result.Data.EventJoinCheckin) +}