diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index 86810157b..50b454ff2 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -26,6 +26,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.util.Streamable; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -164,6 +165,76 @@ private AdminStatistics getReport(String tokenValue, int year, int month) { return admins.getAdminStatistics(year, month); } + /** + * Session-authenticated counterpart to {@code /admin/report}, for the admin dashboard. + *
+ * {@code /admin/report} takes an access token as a request parameter and is therefore listed in
+ * SecurityConfig's permitAll block, which is why it can't simply be reused from a logged-in
+ * browser session. It stays as it is - scripts depend on it - and this serves the same data the
+ * way every other endpoint under {@code /admin/} does, through the session.
+ */
+ @GetMapping(
+ path = "/statistics",
+ produces = MediaType.APPLICATION_JSON_VALUE
+ )
+ @Operation(hidden = true, summary = "Get the admin statistics for the given month and year")
+ @ApiResponse(
+ responseCode = "200",
+ description = "The statistics are returned in JSON format",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON_VALUE,
+ schema = @Schema(implementation = AdminStatisticsJson.class)
+ )
+ )
+ @ApiResponse(
+ responseCode = "400",
+ description = "The year or month is invalid, or lies in the future",
+ content = @Content(schema = @Schema(implementation = AdminStatisticsJson.class))
+ )
+ @ApiResponse(
+ responseCode = "404",
+ description = "No statistics were archived for the given month",
+ content = @Content()
+ )
+ public ResponseEntity
+ * Every figure except {@code downloads} is a point-in-time snapshot rather than an aggregate
+ * over the month: the archival job runs on the first of the following month, so a stored row is
+ * the state shortly after that month ended. {@code downloads} is the one exception, derived as
+ * the growth in {@code downloadsTotal} since the previous month's row - which means that
+ * without a previous row (the first month a registry archives, and for the on-the-fly current
+ * month on a registry that has none yet) it reports every download ever rather than the
+ * month's. That is pre-existing behaviour of the archival job, kept here so the on-the-fly and
+ * archived paths cannot disagree.
+ */
+ public AdminStatistics computeAdminStatistics(int year, int month) {
+ var extensions = repositories.countActiveExtensions();
+ var downloadsTotal = repositories.downloadsTotal();
+
+ var lastDate = LocalDateTime.of(year, month, 1, 0, 0).minusMonths(1);
+ var lastAdminStatistics = repositories
+ .findAdminStatisticsByYearAndMonth(lastDate.getYear(), lastDate.getMonthValue());
+ var lastDownloadsTotal = lastAdminStatistics != null ? lastAdminStatistics.getDownloadsTotal() : 0;
+
+ var statistics = new AdminStatistics();
+ statistics.setYear(year);
+ statistics.setMonth(month);
+ statistics.setExtensions(extensions);
+ statistics.setDownloads(downloadsTotal - lastDownloadsTotal);
+ statistics.setDownloadsTotal(downloadsTotal);
+ statistics.setPublishers(repositories.countActiveExtensionPublishers());
+ statistics.setAverageReviewsPerExtension(repositories.averageNumberOfActiveReviewsPerActiveExtension());
+ statistics.setNamespaceOwners(repositories.countPublishersThatClaimedNamespaceOwnership());
+ statistics.setExtensionsByRating(repositories.countActiveExtensionsGroupedByExtensionReviewRating());
+ statistics.setPublishersByExtensionsPublished(
+ repositories.countActiveExtensionPublishersGroupedByExtensionsPublished());
+ statistics.setTopMostActivePublishingUsers(repositories.topMostActivePublishingUsers(TOP_LIMIT));
+ statistics.setTopNamespaceExtensions(repositories.topNamespaceExtensions(TOP_LIMIT));
+ statistics.setTopNamespaceExtensionVersions(repositories.topNamespaceExtensionVersions(TOP_LIMIT));
+ statistics.setTopMostDownloadedExtensions(repositories.topMostDownloadedExtensions(TOP_LIMIT));
+ return statistics;
}
@Transactional
diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java
index 6fe026925..7bb953cd4 100644
--- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java
+++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java
@@ -108,14 +108,18 @@
import org.eclipse.openvsx.util.LogService;
import org.eclipse.openvsx.util.TargetPlatform;
import org.eclipse.openvsx.util.TargetPlatformVersion;
+import org.eclipse.openvsx.util.TimeUtil;
import org.eclipse.openvsx.util.UUIDService;
import org.eclipse.openvsx.util.VersionService;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
@@ -123,6 +127,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -178,6 +183,11 @@ void noTrustedPublishersByDefault() {
@MockitoBean
ExtensionVersionIntegrityService integrityService;
+ // Only the on-the-fly current month reaches this; every other statistics path reads an
+ // archived row through the mocked RepositoryService above.
+ @MockitoBean
+ AdminStatisticsService adminStatisticsService;
+
@Autowired
MockMvc mockMvc;
@@ -1504,7 +1514,7 @@ void testReportNegativeYearJson() throws Exception {
@Test
void testReportFutureYearCsv() throws Exception {
var token = mockAdminToken();
- var future = LocalDateTime.now().plusYears(1);
+ var future = TimeUtil.getCurrentUTC().plusYears(1);
mockMvc.perform(
get("/admin/report?token={token}&year={year}&month=3", token.getValue(), future.getYear())
.header(HttpHeaders.ACCEPT, "text/csv"))
@@ -1515,7 +1525,7 @@ void testReportFutureYearCsv() throws Exception {
@Test
void testReportFutureYearJson() throws Exception {
var token = mockAdminToken();
- var future = LocalDateTime.now().plusYears(1);
+ var future = TimeUtil.getCurrentUTC().plusYears(1);
mockMvc.perform(
get("/admin/report?token={token}&year={year}&month=3", token.getValue(), future.getYear())
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
@@ -1526,7 +1536,7 @@ void testReportFutureYearJson() throws Exception {
@Test
void testReportMonthLessThanOneCsv() throws Exception {
var token = mockAdminToken();
- var now = LocalDateTime.now();
+ var now = TimeUtil.getCurrentUTC();
mockMvc.perform(
get("/admin/report?token={token}&year={year}&month=0", token.getValue(), now.getYear())
.header(HttpHeaders.ACCEPT, "text/csv"))
@@ -1537,7 +1547,7 @@ void testReportMonthLessThanOneCsv() throws Exception {
@Test
void testReportMonthLessThanOneJson() throws Exception {
var token = mockAdminToken();
- var now = LocalDateTime.now();
+ var now = TimeUtil.getCurrentUTC();
mockMvc.perform(
get("/admin/report?token={token}&year={year}&month=0", token.getValue(), now.getYear())
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
@@ -1548,7 +1558,7 @@ void testReportMonthLessThanOneJson() throws Exception {
@Test
void testReportMonthGreaterThanTwelveCsv() throws Exception {
var token = mockAdminToken();
- var now = LocalDateTime.now();
+ var now = TimeUtil.getCurrentUTC();
mockMvc.perform(
get("/admin/report?token={token}&year={year}&month=13", token.getValue(), now.getYear())
.header(HttpHeaders.ACCEPT, "text/csv"))
@@ -1559,7 +1569,7 @@ void testReportMonthGreaterThanTwelveCsv() throws Exception {
@Test
void testReportMonthGreaterThanTwelveJson() throws Exception {
var token = mockAdminToken();
- var now = LocalDateTime.now();
+ var now = TimeUtil.getCurrentUTC();
mockMvc.perform(
get("/admin/report?token={token}&year={year}&month=13", token.getValue(), now.getYear())
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
@@ -1570,7 +1580,7 @@ void testReportMonthGreaterThanTwelveJson() throws Exception {
@Test
void testReportFutureMonthCsv() throws Exception {
var token = mockAdminToken();
- var future = LocalDateTime.now().plusMonths(1);
+ var future = TimeUtil.getCurrentUTC().plusMonths(1);
mockMvc.perform(
get(
"/admin/report?token={token}&year={year}&month={month}",
@@ -1585,7 +1595,7 @@ void testReportFutureMonthCsv() throws Exception {
@Test
void testReportFutureMonthJson() throws Exception {
var token = mockAdminToken();
- var future = LocalDateTime.now().plusMonths(1);
+ var future = TimeUtil.getCurrentUTC().plusMonths(1);
mockMvc.perform(
get(
"/admin/report?token={token}&year={year}&month={month}",
@@ -1600,7 +1610,7 @@ void testReportFutureMonthJson() throws Exception {
@Test
void testArchivedReportCsv() throws Exception {
var token = mockAdminToken();
- var past = LocalDateTime.now().minusMonths(1);
+ var past = TimeUtil.getCurrentUTC().minusMonths(1);
var year = past.getYear();
var month = past.getMonthValue();
var extensions = 1234;
@@ -1683,7 +1693,7 @@ void testArchivedReportCsv() throws Exception {
@Test
void testArchivedReportJson() throws Exception {
var token = mockAdminToken();
- var past = LocalDateTime.now().minusMonths(1);
+ var past = TimeUtil.getCurrentUTC().minusMonths(1);
var year = past.getYear();
var month = past.getMonthValue();
var extensions = 1234;
@@ -1807,34 +1817,165 @@ void testArchivedReportJson() throws Exception {
})));
}
+ // The month in progress has no archived row - the job only runs on the first of the following
+ // month - so it is computed on demand instead of being rejected as "in the future", which is
+ // what #235 specified and what makes the dashboard in #351 useful before a month has elapsed.
@Test
void testCurrentMonthAdminReportCsv() throws Exception {
var token = mockAdminToken();
- var now = LocalDateTime.now();
+ var now = TimeUtil.getCurrentUTC();
+ var year = now.getYear();
+ var month = now.getMonthValue();
+ when(adminStatisticsService.computeAdminStatistics(year, month))
+ .thenReturn(currentMonthStatistics(year, month));
+
mockMvc.perform(
- get(
- "/admin/report?token={token}&year={year}&month={month}",
- token.getValue(),
- now.getYear(),
- now.getMonthValue())
+ get("/admin/report?token={token}&year={year}&month={month}", token.getValue(), year, month)
.header(HttpHeaders.ACCEPT, "text/csv"))
- .andExpect(status().isBadRequest())
- .andExpect(content().string("Combination of year and month lies in the future"));
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString(year + "," + month + ",1234,423,67890")));
}
@Test
void testCurrentMonthAdminReportJson() throws Exception {
var token = mockAdminToken();
- var now = LocalDateTime.now();
+ var now = TimeUtil.getCurrentUTC();
+ var year = now.getYear();
+ var month = now.getMonthValue();
+ when(adminStatisticsService.computeAdminStatistics(year, month))
+ .thenReturn(currentMonthStatistics(year, month));
+
+ mockMvc.perform(
+ get("/admin/report?token={token}&year={year}&month={month}", token.getValue(), year, month)
+ .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.year").value(year))
+ .andExpect(jsonPath("$.month").value(month))
+ .andExpect(jsonPath("$.extensions").value(1234))
+ .andExpect(jsonPath("$.downloads").value(423));
+ }
+
+ // A month that has ended without an archived row can't be reconstructed - every figure but
+ // downloads is a snapshot of the registry as it was - so it stays a 404 rather than silently
+ // reporting today's numbers under a past month's heading.
+ @Test
+ void testPastMonthWithoutArchivedReportIsNotFound() throws Exception {
+ var token = mockAdminToken();
+ var past = TimeUtil.getCurrentUTC().minusMonths(2);
+ when(repositories.findAdminStatisticsByYearAndMonth(past.getYear(), past.getMonthValue())).thenReturn(null);
+
mockMvc.perform(
get(
"/admin/report?token={token}&year={year}&month={month}",
token.getValue(),
- now.getYear(),
- now.getMonthValue())
+ past.getYear(),
+ past.getMonthValue())
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
- .andExpect(status().isBadRequest())
- .andExpect(content().json(errorJson("Combination of year and month lies in the future")));
+ .andExpect(status().isNotFound());
+ Mockito.verify(adminStatisticsService, never()).computeAdminStatistics(anyInt(), anyInt());
+ }
+
+ // The session-authenticated counterpart to /admin/report, which the dashboard uses because
+ // /admin/report takes its token as a request parameter and is therefore permitAll in
+ // SecurityConfig - unusable from a logged-in browser session.
+ @Test
+ void testStatisticsForAnArchivedMonth() throws Exception {
+ mockAdminUser();
+ var past = TimeUtil.getCurrentUTC().minusMonths(1);
+ var year = past.getYear();
+ var month = past.getMonthValue();
+ when(repositories.findAdminStatisticsByYearAndMonth(year, month))
+ .thenReturn(currentMonthStatistics(year, month));
+
+ mockMvc.perform(
+ get("/admin/statistics?year={year}&month={month}", year, month)
+ .with(user("admin_user").authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
+ .with(csrf().asHeader()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.year").value(year))
+ .andExpect(jsonPath("$.extensions").value(1234));
+ }
+
+ @Test
+ void testStatisticsComputesTheCurrentMonth() throws Exception {
+ mockAdminUser();
+ var now = TimeUtil.getCurrentUTC();
+ var year = now.getYear();
+ var month = now.getMonthValue();
+ when(adminStatisticsService.computeAdminStatistics(year, month))
+ .thenReturn(currentMonthStatistics(year, month));
+
+ mockMvc.perform(
+ get("/admin/statistics?year={year}&month={month}", year, month)
+ .with(user("admin_user").authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
+ .with(csrf().asHeader()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.month").value(month));
+ Mockito.verify(adminStatisticsService).computeAdminStatistics(year, month);
+ }
+
+ @Test
+ void testStatisticsNotAdmin() throws Exception {
+ mockNormalUser();
+ var past = TimeUtil.getCurrentUTC().minusMonths(1);
+ mockMvc.perform(
+ get("/admin/statistics?year={year}&month={month}", past.getYear(), past.getMonthValue())
+ .with(user("test_user"))
+ .with(csrf().asHeader()))
+ .andExpect(status().isForbidden());
+ }
+
+ // The download is a plain link from the dashboard, so it needs its own path (a navigation can't
+ // set an Accept header) and a filename for the browser to save it under.
+ @Test
+ void testStatisticsCsvIsAnAttachment() throws Exception {
+ mockAdminUser();
+ var past = TimeUtil.getCurrentUTC().minusMonths(1);
+ var year = past.getYear();
+ var month = past.getMonthValue();
+ when(repositories.findAdminStatisticsByYearAndMonth(year, month))
+ .thenReturn(currentMonthStatistics(year, month));
+
+ mockMvc.perform(
+ get("/admin/statistics/csv?year={year}&month={month}", year, month)
+ .with(user("admin_user").authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
+ .with(csrf().asHeader()))
+ .andExpect(status().isOk())
+ .andExpect(
+ header().string(
+ HttpHeaders.CONTENT_DISPOSITION,
+ String.format("attachment; filename=\"openvsx-statistics-%d-%02d.csv\"", year, month)))
+ .andExpect(content().string(containsString("year,month,extensions")));
+ }
+
+ @Test
+ void testStatisticsCsvNotAdmin() throws Exception {
+ mockNormalUser();
+ var past = TimeUtil.getCurrentUTC().minusMonths(1);
+ mockMvc.perform(
+ get("/admin/statistics/csv?year={year}&month={month}", past.getYear(), past.getMonthValue())
+ .with(user("test_user"))
+ .with(csrf().asHeader()))
+ .andExpect(status().isForbidden());
+ }
+
+ private AdminStatistics currentMonthStatistics(int year, int month) {
+ var stats = new AdminStatistics();
+ stats.setYear(year);
+ stats.setMonth(month);
+ stats.setExtensions(1234);
+ stats.setDownloads(423);
+ stats.setDownloadsTotal(67890);
+ stats.setPublishers(891);
+ stats.setAverageReviewsPerExtension(4.5);
+ stats.setNamespaceOwners(56);
+ stats.setExtensionsByRating(Map.of(5, 136));
+ stats.setPublishersByExtensionsPublished(Map.of(1, 670));
+ stats.setTopMostActivePublishingUsers(Map.of("u_foo", 93));
+ stats.setTopNamespaceExtensions(Map.of("n_foo", 9));
+ stats.setTopNamespaceExtensionVersions(Map.of("nv_foo", 234));
+ stats.setTopMostDownloadedExtensions(Map.of("foo.bar", 3847L));
+ return stats;
}
@Test
@@ -2694,7 +2835,8 @@ AdminService adminService(
CacheService cache,
JobRequestScheduler scheduler,
MailService mail,
- LogService logs
+ LogService logs,
+ AdminStatisticsService statistics
) {
return new AdminService(
repositories,
@@ -2709,7 +2851,8 @@ AdminService adminService(
cache,
scheduler,
mail,
- logs);
+ logs,
+ statistics);
}
@Bean
diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java
index 18d3b3813..47423588e 100644
--- a/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java
@@ -9,152 +9,35 @@
* ****************************************************************************** */
package org.eclipse.openvsx.admin;
-import java.util.Map;
-
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
import org.mockito.Mockito;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.context.annotation.Bean;
-import org.springframework.test.context.bean.override.mockito.MockitoBean;
-import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.mockito.junit.jupiter.MockitoExtension;
import org.eclipse.openvsx.entities.AdminStatistics;
-import org.eclipse.openvsx.repositories.RepositoryService;
-@ExtendWith(SpringExtension.class)
+/**
+ * The handler is now only the archival half: the computation it used to carry inline lives in
+ * {@link AdminStatisticsService} and is covered by {@link AdminStatisticsServiceTest}.
+ */
+@ExtendWith(MockitoExtension.class)
class AdminStatisticsJobRequestHandlerTest {
- @MockitoBean
- RepositoryService repositories;
-
- @MockitoBean
+ @Mock
AdminStatisticsService service;
- @Autowired
+ @InjectMocks
AdminStatisticsJobRequestHandler handler;
@Test
- void testAdminStatisticsJobRequestHandler() throws Exception {
- var expectedStatistics = mockAdminStatistics();
-
- var request = new AdminStatisticsJobRequest(2023, 11);
- handler.run(request);
- Mockito.verify(service).saveAdminStatistics(expectedStatistics);
- }
-
- @Test
- void testAdminStatisticsJobRequestHandlerWithPreviousStatistics() throws Exception {
- var expectedStatistics = mockAdminStatistics();
- expectedStatistics.setDownloads(678L);
-
- var prevStatistics = new AdminStatistics();
- prevStatistics.setDownloadsTotal(5000);
- Mockito.when(repositories.findAdminStatisticsByYearAndMonth(2023, 10)).thenReturn(prevStatistics);
-
- var request = new AdminStatisticsJobRequest(2023, 11);
- handler.run(request);
- Mockito.verify(service).saveAdminStatistics(expectedStatistics);
- }
-
- @TestConfiguration
- static class TestConfig {
- @Bean
- AdminStatisticsJobRequestHandler adminStatisticsJobRequestHandler(
- RepositoryService repositories,
- AdminStatisticsService service
- ) {
- return new AdminStatisticsJobRequestHandler(repositories, service);
- }
- }
-
- private AdminStatistics mockAdminStatistics() {
- var year = 2023;
- var month = 11;
- var extensions = 1234L;
- var downloadsTotal = 5678L;
- var publishers = 579L;
- var averageReviewsPerExtension = 2.5;
- var namespaceOwners = 268L;
- var extensionsByRating = Map.of(
- 1,
- 34,
- 2,
- 100,
- 3,
- 700,
- 4,
- 150,
- 5,
- 250);
- var publishersByExtensionsPublished = Map.of(
- 1,
- 500,
- 3,
- 70,
- 10,
- 9);
- var topMostActivePublishingUsers = Map.of(
- "foo",
- 400,
- "bar",
- 150,
- "baz",
- 29);
- var topNamespaceExtensions = Map.of(
- "lorum",
- 800,
- "ipsum",
- 400,
- "dolar",
- 34);
- var topNamespaceExtensionVersions = Map.of(
- "lorum",
- 8000,
- "ipsum",
- 2000,
- "dolar",
- 68);
- var topMostDownloadedExtensions = Map.of(
- "lorum.alpha",
- 1200L,
- "ipsum.beta",
- 450L,
- "dolar.omega",
- 300L);
-
- var expectedStatistics = new AdminStatistics();
- expectedStatistics.setYear(year);
- expectedStatistics.setMonth(month);
- expectedStatistics.setExtensions(extensions);
- expectedStatistics.setDownloads(downloadsTotal);
- expectedStatistics.setDownloadsTotal(downloadsTotal);
- expectedStatistics.setPublishers(publishers);
- expectedStatistics.setAverageReviewsPerExtension(averageReviewsPerExtension);
- expectedStatistics.setNamespaceOwners(namespaceOwners);
- expectedStatistics.setExtensionsByRating(extensionsByRating);
- expectedStatistics.setPublishersByExtensionsPublished(publishersByExtensionsPublished);
- expectedStatistics.setTopMostActivePublishingUsers(topMostActivePublishingUsers);
- expectedStatistics.setTopNamespaceExtensions(topNamespaceExtensions);
- expectedStatistics.setTopNamespaceExtensionVersions(topNamespaceExtensionVersions);
- expectedStatistics.setTopMostDownloadedExtensions(topMostDownloadedExtensions);
+ void archivesTheComputedStatisticsForTheRequestedMonth() throws Exception {
+ var statistics = new AdminStatistics();
+ Mockito.when(service.computeAdminStatistics(2023, 11)).thenReturn(statistics);
- Mockito.when(repositories.countActiveExtensions()).thenReturn(extensions);
- Mockito.when(repositories.downloadsTotal()).thenReturn(downloadsTotal);
- Mockito.when(repositories.countActiveExtensionPublishers()).thenReturn(publishers);
- Mockito.when(repositories.averageNumberOfActiveReviewsPerActiveExtension())
- .thenReturn(averageReviewsPerExtension);
- Mockito.when(repositories.countPublishersThatClaimedNamespaceOwnership()).thenReturn(namespaceOwners);
- Mockito.when(repositories.countActiveExtensionsGroupedByExtensionReviewRating()).thenReturn(extensionsByRating);
- Mockito.when(repositories.countActiveExtensionPublishersGroupedByExtensionsPublished())
- .thenReturn(publishersByExtensionsPublished);
- var limit = 10;
- Mockito.when(repositories.topMostActivePublishingUsers(limit)).thenReturn(topMostActivePublishingUsers);
- Mockito.when(repositories.topNamespaceExtensions(limit)).thenReturn(topNamespaceExtensions);
- Mockito.when(repositories.topNamespaceExtensionVersions(limit)).thenReturn(topNamespaceExtensionVersions);
- Mockito.when(repositories.topMostDownloadedExtensions(limit)).thenReturn(topMostDownloadedExtensions);
+ handler.run(new AdminStatisticsJobRequest(2023, 11));
- return expectedStatistics;
+ Mockito.verify(service).saveAdminStatistics(statistics);
}
}
diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsServiceTest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsServiceTest.java
new file mode 100644
index 000000000..2b39b331e
--- /dev/null
+++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsServiceTest.java
@@ -0,0 +1,186 @@
+/** ******************************************************************************
+ * Copyright (c) 2023 Precies. Software Ltd and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ * ****************************************************************************** */
+package org.eclipse.openvsx.admin;
+
+import java.util.Map;
+
+import jakarta.persistence.EntityManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import org.eclipse.openvsx.entities.AdminStatistics;
+import org.eclipse.openvsx.repositories.RepositoryService;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The statistics computation, which used to live inline in {@link AdminStatisticsJobRequestHandler}
+ * and moved here so the archival job and the on-the-fly current month (see
+ * {@code AdminService#getAdminStatistics}) cannot compute them differently.
+ */
+@ExtendWith(MockitoExtension.class)
+class AdminStatisticsServiceTest {
+
+ @Mock
+ EntityManager entityManager;
+
+ @Mock
+ RepositoryService repositories;
+
+ AdminStatisticsService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new AdminStatisticsService(entityManager, repositories);
+ }
+
+ @Test
+ void computesEveryFigureFromTheCurrentState() {
+ var expectedStatistics = mockAdminStatistics();
+
+ var statistics = service.computeAdminStatistics(2023, 11);
+
+ assertThat(statistics).isEqualTo(expectedStatistics);
+ }
+
+ // downloads is the one figure that isn't a snapshot: it's the growth in downloadsTotal since
+ // the previous month's row.
+ @Test
+ void derivesTheMonthsDownloadsFromThePreviousMonth() {
+ var expectedStatistics = mockAdminStatistics();
+ expectedStatistics.setDownloads(678L);
+
+ var prevStatistics = new AdminStatistics();
+ prevStatistics.setDownloadsTotal(5000);
+ Mockito.when(repositories.findAdminStatisticsByYearAndMonth(2023, 10)).thenReturn(prevStatistics);
+
+ var statistics = service.computeAdminStatistics(2023, 11);
+
+ assertThat(statistics.getDownloads()).isEqualTo(678L);
+ assertThat(statistics).isEqualTo(expectedStatistics);
+ }
+
+ // Without a previous row there is nothing to subtract, so the month reports every download the
+ // registry has ever served. Pre-existing behaviour of the archival job, pinned here because the
+ // on-the-fly path now hits it too on a registry that has never archived a month.
+ @Test
+ void reportsEveryDownloadWhenNoPreviousMonthWasArchived() {
+ mockAdminStatistics();
+ Mockito.when(repositories.findAdminStatisticsByYearAndMonth(2023, 10)).thenReturn(null);
+
+ var statistics = service.computeAdminStatistics(2023, 11);
+
+ assertThat(statistics.getDownloads()).isEqualTo(statistics.getDownloadsTotal());
+ }
+
+ // The month asked for is what the row is labelled with, not the month the computation runs in.
+ @Test
+ void labelsTheStatisticsWithTheRequestedMonth() {
+ mockAdminStatistics();
+
+ var statistics = service.computeAdminStatistics(2023, 11);
+
+ assertThat(statistics.getYear()).isEqualTo(2023);
+ assertThat(statistics.getMonth()).isEqualTo(11);
+ }
+
+ private AdminStatistics mockAdminStatistics() {
+ var year = 2023;
+ var month = 11;
+ var extensions = 1234L;
+ var downloadsTotal = 5678L;
+ var publishers = 579L;
+ var averageReviewsPerExtension = 2.5;
+ var namespaceOwners = 268L;
+ var extensionsByRating = Map.of(
+ 1,
+ 34,
+ 2,
+ 100,
+ 3,
+ 700,
+ 4,
+ 150,
+ 5,
+ 250);
+ var publishersByExtensionsPublished = Map.of(
+ 1,
+ 500,
+ 3,
+ 70,
+ 10,
+ 9);
+ var topMostActivePublishingUsers = Map.of(
+ "foo",
+ 400,
+ "bar",
+ 150,
+ "baz",
+ 29);
+ var topNamespaceExtensions = Map.of(
+ "lorum",
+ 800,
+ "ipsum",
+ 400,
+ "dolar",
+ 34);
+ var topNamespaceExtensionVersions = Map.of(
+ "lorum",
+ 8000,
+ "ipsum",
+ 2000,
+ "dolar",
+ 68);
+ var topMostDownloadedExtensions = Map.of(
+ "lorum.alpha",
+ 1200L,
+ "ipsum.beta",
+ 450L,
+ "dolar.omega",
+ 300L);
+
+ var expectedStatistics = new AdminStatistics();
+ expectedStatistics.setYear(year);
+ expectedStatistics.setMonth(month);
+ expectedStatistics.setExtensions(extensions);
+ expectedStatistics.setDownloads(downloadsTotal);
+ expectedStatistics.setDownloadsTotal(downloadsTotal);
+ expectedStatistics.setPublishers(publishers);
+ expectedStatistics.setAverageReviewsPerExtension(averageReviewsPerExtension);
+ expectedStatistics.setNamespaceOwners(namespaceOwners);
+ expectedStatistics.setExtensionsByRating(extensionsByRating);
+ expectedStatistics.setPublishersByExtensionsPublished(publishersByExtensionsPublished);
+ expectedStatistics.setTopMostActivePublishingUsers(topMostActivePublishingUsers);
+ expectedStatistics.setTopNamespaceExtensions(topNamespaceExtensions);
+ expectedStatistics.setTopNamespaceExtensionVersions(topNamespaceExtensionVersions);
+ expectedStatistics.setTopMostDownloadedExtensions(topMostDownloadedExtensions);
+
+ Mockito.when(repositories.countActiveExtensions()).thenReturn(extensions);
+ Mockito.when(repositories.downloadsTotal()).thenReturn(downloadsTotal);
+ Mockito.when(repositories.countActiveExtensionPublishers()).thenReturn(publishers);
+ Mockito.when(repositories.averageNumberOfActiveReviewsPerActiveExtension())
+ .thenReturn(averageReviewsPerExtension);
+ Mockito.when(repositories.countPublishersThatClaimedNamespaceOwnership()).thenReturn(namespaceOwners);
+ Mockito.when(repositories.countActiveExtensionsGroupedByExtensionReviewRating()).thenReturn(extensionsByRating);
+ Mockito.when(repositories.countActiveExtensionPublishersGroupedByExtensionsPublished())
+ .thenReturn(publishersByExtensionsPublished);
+ var limit = 10;
+ Mockito.when(repositories.topMostActivePublishingUsers(limit)).thenReturn(topMostActivePublishingUsers);
+ Mockito.when(repositories.topNamespaceExtensions(limit)).thenReturn(topNamespaceExtensions);
+ Mockito.when(repositories.topNamespaceExtensionVersions(limit)).thenReturn(topNamespaceExtensionVersions);
+ Mockito.when(repositories.topMostDownloadedExtensions(limit)).thenReturn(topMostDownloadedExtensions);
+
+ return expectedStatistics;
+ }
+}
diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts
index 5c8edd5d5..08222cdc3 100644
--- a/webui/src/extension-registry-service.ts
+++ b/webui/src/extension-registry-service.ts
@@ -58,7 +58,8 @@ import {
TrustedPublisherStatus,
ConsistencyCheckList,
ConsistencyFindingList,
- SearchIndex
+ SearchIndex,
+ AdminStatistics
} from './extension-registry-types';
import { createAbsoluteURL, addQuery } from './utils';
import { sendRequest, ErrorResponse, sendNonRetriableRequest, sendStrictRequest } from './server-request';
@@ -772,6 +773,12 @@ export interface AdminService {
getSettings(abortController: AbortController): Promise
+
+