diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 488193681..a6a87b1f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: env: STREAM_KEY: ${{ secrets.STREAM_KEY }} STREAM_SECRET: ${{ secrets.STREAM_SECRET }} + STREAM_MULTI_TENANT_KEY: ${{ secrets.STREAM_MULTI_TENANT_KEY }} + STREAM_MULTI_TENANT_SECRET: ${{ secrets.STREAM_MULTI_TENANT_SECRET }} run: | ./gradlew spotlessCheck --no-daemon ./gradlew javadoc --no-daemon diff --git a/src/main/java/io/getstream/chat/java/models/TeamUsageStats.java b/src/main/java/io/getstream/chat/java/models/TeamUsageStats.java new file mode 100644 index 000000000..aca813148 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/models/TeamUsageStats.java @@ -0,0 +1,233 @@ +package io.getstream.chat.java.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsRequestData.QueryTeamUsageStatsRequest; +import io.getstream.chat.java.models.framework.StreamRequest; +import io.getstream.chat.java.models.framework.StreamResponseObject; +import io.getstream.chat.java.services.StatsService; +import io.getstream.chat.java.services.framework.Client; +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import retrofit2.Call; + +/** Team-level usage statistics for multi-tenant apps. */ +@Data +@NoArgsConstructor +public class TeamUsageStats { + + /** Team identifier (empty string for users not assigned to any team). */ + @NotNull + @JsonProperty("team") + private String team; + + // Daily activity metrics (total = SUM of daily values) + + /** Daily active users. */ + @NotNull + @JsonProperty("users_daily") + private MetricStats usersDaily; + + /** Daily messages sent. */ + @NotNull + @JsonProperty("messages_daily") + private MetricStats messagesDaily; + + /** Daily translations. */ + @NotNull + @JsonProperty("translations_daily") + private MetricStats translationsDaily; + + /** Daily image moderations. */ + @NotNull + @JsonProperty("image_moderations_daily") + private MetricStats imageModerationDaily; + + // Peak metrics (total = MAX of daily values) + + /** Peak concurrent users. */ + @NotNull + @JsonProperty("concurrent_users") + private MetricStats concurrentUsers; + + /** Peak concurrent connections. */ + @NotNull + @JsonProperty("concurrent_connections") + private MetricStats concurrentConnections; + + // Rolling/cumulative metrics (total = LATEST daily value) + + /** Total users. */ + @NotNull + @JsonProperty("users_total") + private MetricStats usersTotal; + + /** Users active in last 24 hours. */ + @NotNull + @JsonProperty("users_last_24_hours") + private MetricStats usersLast24Hours; + + /** MAU - users active in last 30 days. */ + @NotNull + @JsonProperty("users_last_30_days") + private MetricStats usersLast30Days; + + /** Users active this month. */ + @NotNull + @JsonProperty("users_month_to_date") + private MetricStats usersMonthToDate; + + /** Engaged MAU. */ + @NotNull + @JsonProperty("users_engaged_last_30_days") + private MetricStats usersEngagedLast30Days; + + /** Engaged users this month. */ + @NotNull + @JsonProperty("users_engaged_month_to_date") + private MetricStats usersEngagedMonthToDate; + + /** Total messages. */ + @NotNull + @JsonProperty("messages_total") + private MetricStats messagesTotal; + + /** Messages in last 24 hours. */ + @NotNull + @JsonProperty("messages_last_24_hours") + private MetricStats messagesLast24Hours; + + /** Messages in last 30 days. */ + @NotNull + @JsonProperty("messages_last_30_days") + private MetricStats messagesLast30Days; + + /** Messages this month. */ + @NotNull + @JsonProperty("messages_month_to_date") + private MetricStats messagesMonthToDate; + + /** Statistics for a single metric with optional daily breakdown. */ + @Data + @NoArgsConstructor + public static class MetricStats { + /** Per-day values (only present in daily mode). */ + @Nullable + @JsonProperty("daily") + private List daily; + + /** Aggregated total value. */ + @NotNull + @JsonProperty("total") + private Long total; + } + + /** Represents a metric value for a specific date. */ + @Data + @NoArgsConstructor + public static class DailyValue { + /** Date in YYYY-MM-DD format. */ + @NotNull + @JsonProperty("date") + private String date; + + /** Metric value for this date. */ + @NotNull + @JsonProperty("value") + private Long value; + } + + @Builder( + builderClassName = "QueryTeamUsageStatsRequest", + builderMethodName = "", + buildMethodName = "internalBuild") + @Getter + @EqualsAndHashCode + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class QueryTeamUsageStatsRequestData { + /** + * Month in YYYY-MM format (e.g., '2026-01'). Mutually exclusive with start_date/end_date. + * Returns aggregated monthly values. + */ + @Nullable + @JsonProperty("month") + private String month; + + /** + * Start date in YYYY-MM-DD format. Used with end_date for custom date range. Returns daily + * breakdown. + */ + @Nullable + @JsonProperty("start_date") + private String startDate; + + /** + * End date in YYYY-MM-DD format. Used with start_date for custom date range. Returns daily + * breakdown. + */ + @Nullable + @JsonProperty("end_date") + private String endDate; + + /** Maximum number of teams to return per page (default: 30, max: 30). */ + @Nullable + @JsonProperty("limit") + private Integer limit; + + /** Cursor for pagination to fetch next page of teams. */ + @Nullable + @JsonProperty("next") + private String next; + + public static class QueryTeamUsageStatsRequest + extends StreamRequest { + @Override + protected Call generateCall(Client client) { + return client.create(StatsService.class).queryTeamUsageStats(this.internalBuild()); + } + } + } + + @Data + @NoArgsConstructor + @EqualsAndHashCode(callSuper = true) + public static class QueryTeamUsageStatsResponse extends StreamResponseObject { + /** Array of team usage statistics. */ + @NotNull + @JsonProperty("teams") + private List teams; + + /** Cursor for pagination to fetch next page. */ + @Nullable + @JsonProperty("next") + private String next; + } + + /** + * Queries team-level usage statistics from the warehouse database. + * + *

Returns usage metrics grouped by team with cursor-based pagination. + * + *

Date Range Options (mutually exclusive): + * + *

    + *
  • Use 'month' parameter (YYYY-MM format) for monthly aggregated values + *
  • Use 'startDate'/'endDate' parameters (YYYY-MM-DD format) for daily breakdown + *
  • If neither provided, defaults to current month (monthly mode) + *
+ * + *

This endpoint is server-side only. + * + * @return the created request + */ + @NotNull + public static QueryTeamUsageStatsRequest queryTeamUsageStats() { + return new QueryTeamUsageStatsRequest(); + } +} diff --git a/src/main/java/io/getstream/chat/java/services/StatsService.java b/src/main/java/io/getstream/chat/java/services/StatsService.java new file mode 100644 index 000000000..05772042a --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/StatsService.java @@ -0,0 +1,14 @@ +package io.getstream.chat.java.services; + +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsRequestData; +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsResponse; +import org.jetbrains.annotations.NotNull; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.POST; + +public interface StatsService { + @POST("stats/team_usage") + Call queryTeamUsageStats( + @NotNull @Body QueryTeamUsageStatsRequestData queryTeamUsageStatsRequestData); +} diff --git a/src/test/java/io/getstream/chat/java/TeamUsageStatsIntegrationTest.java b/src/test/java/io/getstream/chat/java/TeamUsageStatsIntegrationTest.java new file mode 100644 index 000000000..eeb51fd77 --- /dev/null +++ b/src/test/java/io/getstream/chat/java/TeamUsageStatsIntegrationTest.java @@ -0,0 +1,554 @@ +package io.getstream.chat.java; + +import static org.junit.jupiter.api.Assertions.*; + +import io.getstream.chat.java.exceptions.StreamException; +import io.getstream.chat.java.models.TeamUsageStats; +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsResponse; +import io.getstream.chat.java.services.framework.DefaultClient; +import java.util.Properties; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for Team Usage Stats API. Uses dedicated multi-tenant test app credentials from + * STREAM_MULTI_TENANT_KEY and STREAM_MULTI_TENANT_SECRET environment variables. + * + *

These tests verify that the SDK correctly parses all response data from the backend. + */ +public class TeamUsageStatsIntegrationTest { + + private static DefaultClient originalClient; + + @BeforeAll + static void setup() { + // Save the original client to restore after tests + originalClient = DefaultClient.getInstance(); + + String apiKey = System.getenv("STREAM_MULTI_TENANT_KEY"); + String apiSecret = System.getenv("STREAM_MULTI_TENANT_SECRET"); + + if (apiKey == null || apiKey.isEmpty() || apiSecret == null || apiSecret.isEmpty()) { + throw new IllegalStateException( + "Multi-tenant test app credentials are missing. " + + "Set STREAM_MULTI_TENANT_KEY and STREAM_MULTI_TENANT_SECRET environment variables."); + } + + Properties props = new Properties(); + props.setProperty("io.getstream.chat.apiKey", apiKey); + props.setProperty("io.getstream.chat.apiSecret", apiSecret); + + DefaultClient.setInstance(new DefaultClient(props)); + } + + @AfterAll + static void teardown() { + // Restore the original client so other tests use the correct credentials + if (originalClient != null) { + DefaultClient.setInstance(originalClient); + } + } + + @Nested + @DisplayName("Basic Queries") + class BasicQueries { + + @Test + @DisplayName("No parameters returns teams") + void noParametersReturnsTeams() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + + assertNotNull(response); + assertNotNull(response.getTeams()); + assertTrue(response.getTeams().size() > 0, "Should return at least one team"); + assertNotNull(response.getDuration()); + } + + @Test + @DisplayName("Empty request returns teams") + void emptyRequestReturnsTeams() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + + assertNotNull(response.getTeams()); + } + } + + @Nested + @DisplayName("Month Parameter") + class MonthParameter { + + @Test + @DisplayName("Valid month format works") + void validMonthWorks() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().month("2026-02").request(); + + assertNotNull(response.getTeams()); + assertTrue(response.getTeams().size() > 0); + } + + @Test + @DisplayName("Past month with no data returns empty") + void pastMonthReturnsEmpty() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().month("2025-01").request(); + + assertNotNull(response.getTeams()); + assertEquals(0, response.getTeams().size()); + } + + @Test + @DisplayName("Invalid month format throws error") + void invalidMonthThrows() { + assertThrows( + StreamException.class, + () -> TeamUsageStats.queryTeamUsageStats().month("invalid").request()); + } + + @Test + @DisplayName("Wrong length month throws error") + void wrongLengthMonthThrows() { + assertThrows( + StreamException.class, + () -> TeamUsageStats.queryTeamUsageStats().month("2026").request()); + } + } + + @Nested + @DisplayName("Date Range Parameters") + class DateRangeParameters { + + @Test + @DisplayName("Valid date range works") + void validDateRangeWorks() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats() + .startDate("2026-02-01") + .endDate("2026-02-17") + .request(); + + assertNotNull(response.getTeams()); + assertTrue(response.getTeams().size() > 0); + } + + @Test + @DisplayName("Single day range works") + void singleDayRangeWorks() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats() + .startDate("2026-02-17") + .endDate("2026-02-17") + .request(); + + assertNotNull(response.getTeams()); + } + + @Test + @DisplayName("Invalid start_date throws error") + void invalidStartDateThrows() { + assertThrows( + StreamException.class, + () -> TeamUsageStats.queryTeamUsageStats().startDate("bad").request()); + } + + @Test + @DisplayName("end_date before start_date throws error") + void endBeforeStartThrows() { + assertThrows( + StreamException.class, + () -> + TeamUsageStats.queryTeamUsageStats() + .startDate("2026-02-20") + .endDate("2026-02-10") + .request()); + } + } + + @Nested + @DisplayName("Pagination") + class Pagination { + + @Test + @DisplayName("limit=3 returns exactly 3 teams") + void limitReturnsCorrectCount() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().limit(3).request(); + + assertEquals(3, response.getTeams().size()); + } + + @Test + @DisplayName("limit returns next cursor when more data exists") + void limitReturnsNextCursor() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().limit(3).request(); + + assertNotNull(response.getNext()); + assertFalse(response.getNext().isEmpty()); + } + + @Test + @DisplayName("Pagination with next cursor returns different teams") + void paginationReturnsDifferentTeams() throws StreamException { + QueryTeamUsageStatsResponse page1 = TeamUsageStats.queryTeamUsageStats().limit(3).request(); + QueryTeamUsageStatsResponse page2 = + TeamUsageStats.queryTeamUsageStats().limit(3).next(page1.getNext()).request(); + + // Verify no overlap between pages + for (var t1 : page1.getTeams()) { + for (var t2 : page2.getTeams()) { + assertNotEquals(t1.getTeam(), t2.getTeam(), "Pages should not have overlapping teams"); + } + } + } + + @Test + @DisplayName("limit=30 (max) works") + void maxLimitWorks() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().limit(30).request(); + + assertNotNull(response.getTeams()); + } + + @Test + @DisplayName("limit > 30 throws error") + void overMaxLimitThrows() { + assertThrows( + StreamException.class, () -> TeamUsageStats.queryTeamUsageStats().limit(31).request()); + } + + @Test + @DisplayName("limit + month combined works") + void limitWithMonthWorks() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().limit(2).month("2026-02").request(); + + assertEquals(2, response.getTeams().size()); + } + + @Test + @DisplayName("limit + date range combined works") + void limitWithDateRangeWorks() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats() + .limit(2) + .startDate("2026-02-01") + .endDate("2026-02-17") + .request(); + + assertEquals(2, response.getTeams().size()); + } + } + + @Nested + @DisplayName("Response Structure Validation") + class ResponseStructure { + + @Test + @DisplayName("Response has duration field") + void responseHasDuration() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + + assertNotNull(response.getDuration()); + } + + @Test + @DisplayName("Teams have team field") + void teamsHaveTeamField() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + + // team field exists (may be empty string for default team) + assertDoesNotThrow(() -> response.getTeams().get(0).getTeam()); + } + + @Test + @DisplayName("All 16 metrics are present and parseable") + void allMetricsPresent() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + var team = response.getTeams().get(0); + + // Daily activity metrics + assertNotNull(team.getUsersDaily(), "users_daily should be present"); + assertNotNull(team.getMessagesDaily(), "messages_daily should be present"); + assertNotNull(team.getTranslationsDaily(), "translations_daily should be present"); + assertNotNull(team.getImageModerationDaily(), "image_moderations_daily should be present"); + + // Peak metrics + assertNotNull(team.getConcurrentUsers(), "concurrent_users should be present"); + assertNotNull(team.getConcurrentConnections(), "concurrent_connections should be present"); + + // Rolling/cumulative metrics + assertNotNull(team.getUsersTotal(), "users_total should be present"); + assertNotNull(team.getUsersLast24Hours(), "users_last_24_hours should be present"); + assertNotNull(team.getUsersLast30Days(), "users_last_30_days should be present"); + assertNotNull(team.getUsersMonthToDate(), "users_month_to_date should be present"); + assertNotNull( + team.getUsersEngagedLast30Days(), "users_engaged_last_30_days should be present"); + assertNotNull( + team.getUsersEngagedMonthToDate(), "users_engaged_month_to_date should be present"); + assertNotNull(team.getMessagesTotal(), "messages_total should be present"); + assertNotNull(team.getMessagesLast24Hours(), "messages_last_24_hours should be present"); + assertNotNull(team.getMessagesLast30Days(), "messages_last_30_days should be present"); + assertNotNull(team.getMessagesMonthToDate(), "messages_month_to_date should be present"); + } + + @Test + @DisplayName("Metrics have total field with valid value") + void metricsHaveTotal() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + var team = response.getTeams().get(0); + + // Verify total field is present and non-null + assertNotNull(team.getMessagesTotal().getTotal()); + assertNotNull(team.getUsersDaily().getTotal()); + assertNotNull(team.getConcurrentUsers().getTotal()); + } + + @Test + @DisplayName("MetricStats total values are non-negative") + void metricTotalsNonNegative() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + + for (var team : response.getTeams()) { + assertTrue(team.getMessagesTotal().getTotal() >= 0, "messages_total should be >= 0"); + assertTrue(team.getUsersDaily().getTotal() >= 0, "users_daily should be >= 0"); + assertTrue(team.getConcurrentUsers().getTotal() >= 0, "concurrent_users should be >= 0"); + } + } + } + + @Nested + @DisplayName("Data Correctness - Date Range Query") + class DataCorrectnessDateRange { + + /** + * Verifies exact metric values for sdk-test-team-1/2/3 using date range query. + * + *

Expected values for each sdk-test-team-N: + * + *

    + *
  • users_daily: 0, messages_daily: 100 + *
  • translations_daily: 0, image_moderations_daily: 0 + *
  • concurrent_users: 0, concurrent_connections: 0 + *
  • users_total: 5, users_last_24_hours: 5, users_last_30_days: 5, users_month_to_date: 5 + *
  • users_engaged_last_30_days: 0, users_engaged_month_to_date: 0 + *
  • messages_total: 100, messages_last_24_hours: 100, messages_last_30_days: 100, + * messages_month_to_date: 100 + *
+ */ + @Test + @DisplayName("Date range: sdk-test-team-1 exact values") + void dateRangeSdkTestTeam1() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats() + .startDate("2026-02-17") + .endDate("2026-02-18") + .request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-1"); + assertNotNull(team, "sdk-test-team-1 should exist"); + assertAllMetricsExact(team, "sdk-test-team-1"); + } + + @Test + @DisplayName("Date range: sdk-test-team-2 exact values") + void dateRangeSdkTestTeam2() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats() + .startDate("2026-02-17") + .endDate("2026-02-18") + .request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-2"); + assertNotNull(team, "sdk-test-team-2 should exist"); + assertAllMetricsExact(team, "sdk-test-team-2"); + } + + @Test + @DisplayName("Date range: sdk-test-team-3 exact values") + void dateRangeSdkTestTeam3() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats() + .startDate("2026-02-17") + .endDate("2026-02-18") + .request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-3"); + assertNotNull(team, "sdk-test-team-3 should exist"); + assertAllMetricsExact(team, "sdk-test-team-3"); + } + } + + @Nested + @DisplayName("Data Correctness - Month Query") + class DataCorrectnessMonth { + + @Test + @DisplayName("Month query: sdk-test-team-1 exact values") + void monthQuerySdkTestTeam1() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().month("2026-02").request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-1"); + assertNotNull(team, "sdk-test-team-1 should exist"); + assertAllMetricsExact(team, "sdk-test-team-1"); + } + + @Test + @DisplayName("Month query: sdk-test-team-2 exact values") + void monthQuerySdkTestTeam2() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().month("2026-02").request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-2"); + assertNotNull(team, "sdk-test-team-2 should exist"); + assertAllMetricsExact(team, "sdk-test-team-2"); + } + + @Test + @DisplayName("Month query: sdk-test-team-3 exact values") + void monthQuerySdkTestTeam3() throws StreamException { + QueryTeamUsageStatsResponse response = + TeamUsageStats.queryTeamUsageStats().month("2026-02").request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-3"); + assertNotNull(team, "sdk-test-team-3 should exist"); + assertAllMetricsExact(team, "sdk-test-team-3"); + } + } + + @Nested + @DisplayName("Data Correctness - No Parameters Query") + class DataCorrectnessNoParams { + + @Test + @DisplayName("No params: sdk-test-team-1 exact values") + void noParamsSdkTestTeam1() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-1"); + assertNotNull(team, "sdk-test-team-1 should exist"); + assertAllMetricsExact(team, "sdk-test-team-1"); + } + + @Test + @DisplayName("No params: sdk-test-team-2 exact values") + void noParamsSdkTestTeam2() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-2"); + assertNotNull(team, "sdk-test-team-2 should exist"); + assertAllMetricsExact(team, "sdk-test-team-2"); + } + + @Test + @DisplayName("No params: sdk-test-team-3 exact values") + void noParamsSdkTestTeam3() throws StreamException { + QueryTeamUsageStatsResponse response = TeamUsageStats.queryTeamUsageStats().request(); + + TeamUsageStats team = findTeamByName(response, "sdk-test-team-3"); + assertNotNull(team, "sdk-test-team-3 should exist"); + assertAllMetricsExact(team, "sdk-test-team-3"); + } + } + + @Nested + @DisplayName("Data Correctness - Pagination Query") + class DataCorrectnessPagination { + + @Test + @DisplayName("Pagination: finds sdk-test-team-1 with exact values across pages") + void paginationFindsSdkTestTeam1() throws StreamException { + TeamUsageStats team = findTeamAcrossPages("sdk-test-team-1"); + assertNotNull(team, "sdk-test-team-1 should exist across paginated results"); + assertAllMetricsExact(team, "sdk-test-team-1"); + } + + @Test + @DisplayName("Pagination: finds sdk-test-team-2 with exact values across pages") + void paginationFindsSdkTestTeam2() throws StreamException { + TeamUsageStats team = findTeamAcrossPages("sdk-test-team-2"); + assertNotNull(team, "sdk-test-team-2 should exist across paginated results"); + assertAllMetricsExact(team, "sdk-test-team-2"); + } + + @Test + @DisplayName("Pagination: finds sdk-test-team-3 with exact values across pages") + void paginationFindsSdkTestTeam3() throws StreamException { + TeamUsageStats team = findTeamAcrossPages("sdk-test-team-3"); + assertNotNull(team, "sdk-test-team-3 should exist across paginated results"); + assertAllMetricsExact(team, "sdk-test-team-3"); + } + + private TeamUsageStats findTeamAcrossPages(String teamName) throws StreamException { + String nextCursor = null; + int maxPages = 10; // Safety limit + + for (int page = 0; page < maxPages; page++) { + var requestBuilder = TeamUsageStats.queryTeamUsageStats().limit(5); + if (nextCursor != null) { + requestBuilder = requestBuilder.next(nextCursor); + } + + QueryTeamUsageStatsResponse response = requestBuilder.request(); + TeamUsageStats found = findTeamByName(response, teamName); + if (found != null) { + return found; + } + + nextCursor = response.getNext(); + if (nextCursor == null || nextCursor.isEmpty()) { + break; // No more pages + } + } + return null; + } + } + + // Helper methods shared across nested classes + private static TeamUsageStats findTeamByName( + QueryTeamUsageStatsResponse response, String teamName) { + for (TeamUsageStats team : response.getTeams()) { + if (teamName.equals(team.getTeam())) { + return team; + } + } + return null; + } + + private static void assertAllMetricsExact(TeamUsageStats team, String teamName) { + // Daily activity metrics + assertEquals(0, team.getUsersDaily().getTotal(), teamName + " users_daily"); + assertEquals(100, team.getMessagesDaily().getTotal(), teamName + " messages_daily"); + assertEquals(0, team.getTranslationsDaily().getTotal(), teamName + " translations_daily"); + assertEquals( + 0, team.getImageModerationDaily().getTotal(), teamName + " image_moderations_daily"); + + // Peak metrics + assertEquals(0, team.getConcurrentUsers().getTotal(), teamName + " concurrent_users"); + assertEquals( + 0, team.getConcurrentConnections().getTotal(), teamName + " concurrent_connections"); + + // User rolling/cumulative metrics + assertEquals(5, team.getUsersTotal().getTotal(), teamName + " users_total"); + assertEquals(5, team.getUsersLast24Hours().getTotal(), teamName + " users_last_24_hours"); + assertEquals(5, team.getUsersLast30Days().getTotal(), teamName + " users_last_30_days"); + assertEquals(5, team.getUsersMonthToDate().getTotal(), teamName + " users_month_to_date"); + assertEquals( + 0, team.getUsersEngagedLast30Days().getTotal(), teamName + " users_engaged_last_30_days"); + assertEquals( + 0, team.getUsersEngagedMonthToDate().getTotal(), teamName + " users_engaged_month_to_date"); + + // Message rolling/cumulative metrics + assertEquals(100, team.getMessagesTotal().getTotal(), teamName + " messages_total"); + assertEquals( + 100, team.getMessagesLast24Hours().getTotal(), teamName + " messages_last_24_hours"); + assertEquals(100, team.getMessagesLast30Days().getTotal(), teamName + " messages_last_30_days"); + assertEquals( + 100, team.getMessagesMonthToDate().getTotal(), teamName + " messages_month_to_date"); + } +} diff --git a/src/test/java/io/getstream/chat/java/TeamUsageStatsTest.java b/src/test/java/io/getstream/chat/java/TeamUsageStatsTest.java new file mode 100644 index 000000000..09ab901e3 --- /dev/null +++ b/src/test/java/io/getstream/chat/java/TeamUsageStatsTest.java @@ -0,0 +1,77 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.models.TeamUsageStats; +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsResponse; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Basic tests for Team Usage Stats API using regular (non-multi-tenant) app credentials. Since the + * regular app doesn't have multi-tenant enabled, teams will always be empty. Full data verification + * is done in TeamUsageStatsIntegrationTest with multi-tenant credentials. + */ +public class TeamUsageStatsTest { + + @DisplayName("Can query team usage stats with default options") + @Test + void whenQueryingTeamUsageStatsWithDefaultOptions_thenNoException() { + QueryTeamUsageStatsResponse response = + Assertions.assertDoesNotThrow(() -> TeamUsageStats.queryTeamUsageStats().request()); + + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getTeams()); + // Regular app doesn't have multi-tenant, so teams is empty + Assertions.assertTrue(response.getTeams().isEmpty()); + } + + @DisplayName("Can query team usage stats with month parameter") + @Test + void whenQueryingTeamUsageStatsWithMonth_thenNoException() { + String currentMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")); + + QueryTeamUsageStatsResponse response = + Assertions.assertDoesNotThrow( + () -> TeamUsageStats.queryTeamUsageStats().month(currentMonth).request()); + + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getTeams()); + Assertions.assertTrue(response.getTeams().isEmpty()); + } + + @DisplayName("Can query team usage stats with date range") + @Test + void whenQueryingTeamUsageStatsWithDateRange_thenNoException() { + LocalDate endDate = LocalDate.now(); + LocalDate startDate = endDate.minusDays(7); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + QueryTeamUsageStatsResponse response = + Assertions.assertDoesNotThrow( + () -> + TeamUsageStats.queryTeamUsageStats() + .startDate(startDate.format(formatter)) + .endDate(endDate.format(formatter)) + .request()); + + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getTeams()); + Assertions.assertTrue(response.getTeams().isEmpty()); + } + + @DisplayName("Can query team usage stats with pagination") + @Test + void whenQueryingTeamUsageStatsWithPagination_thenNoException() { + QueryTeamUsageStatsResponse response = + Assertions.assertDoesNotThrow( + () -> TeamUsageStats.queryTeamUsageStats().limit(10).request()); + + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getTeams()); + Assertions.assertTrue(response.getTeams().isEmpty()); + // No next cursor when teams is empty + Assertions.assertTrue(response.getNext() == null || response.getNext().isEmpty()); + } +}