@@ -17,6 +17,7 @@ import {
1717// Mock the api module
1818jest . mock ( '../api' , ( ) => ( {
1919 getApiKeys : jest . fn ( ) ,
20+ getApiKeysUsageStats : jest . fn ( ) ,
2021 createApiKey : jest . fn ( ) ,
2122 revokeApiKey : jest . fn ( ) ,
2223 deleteApiKey : jest . fn ( )
@@ -36,6 +37,7 @@ describe('API Keys Module', () => {
3637 beforeEach ( ( ) => {
3738 // Reset DOM
3839 document . body . innerHTML = `
40+ <div id="apikeys-usage-summary"></div>
3941 <div id="apikeys-list"></div>
4042 <button id="create-apikey-btn"></button>
4143 <div id="create-apikey-modal" class="hidden">
@@ -59,6 +61,15 @@ describe('API Keys Module', () => {
5961 // clears call history, not the mockResolvedValueOnce queue.
6062 mockConfirmDialog . mockReset ( ) ;
6163 mockConfirmDialog . mockImplementation ( ( ) => Promise . resolve ( true ) ) ;
64+ // Default the usage-stats endpoint to an empty response so the
65+ // parallel-load path in loadApiKeys doesn't reject in tests that
66+ // only set up `getApiKeys`. Individual tests can override.
67+ ( api . getApiKeysUsageStats as jest . Mock ) . mockResolvedValue ( {
68+ total_active : 0 ,
69+ total_requests_24h : 0 ,
70+ total_requests_lifetime : 0 ,
71+ top_keys : [ ] ,
72+ } ) ;
6273 } ) ;
6374
6475 describe ( 'loadApiKeys' , ( ) => {
@@ -1000,4 +1011,132 @@ describe('API Keys Module', () => {
10001011 expect ( span ?. getAttribute ( 'title' ) ) . toBe ( new Date ( recentTs ) . toISOString ( ) ) ;
10011012 } ) ;
10021013 } ) ;
1014+
1015+ // Usage stats (issue #340/#344 deferred sub-task) — section-level summary
1016+ // card above the table + per-row request count columns.
1017+ describe ( 'Usage stats' , ( ) => {
1018+ test ( 'renders summary tiles when stats load successfully' , async ( ) => {
1019+ ( api . getApiKeys as jest . Mock ) . mockResolvedValue ( { api_keys : [ ] } ) ;
1020+ ( api . getApiKeysUsageStats as jest . Mock ) . mockResolvedValue ( {
1021+ total_active : 2 ,
1022+ total_requests_24h : 42 ,
1023+ total_requests_lifetime : 1234 ,
1024+ top_keys : [
1025+ { id : 'key-1' , name : 'Busy' , key_prefix : 'aaaa1111' , request_count_24h : 30 } ,
1026+ { id : 'key-2' , name : 'Medium' , key_prefix : 'bbbb2222' , request_count_24h : 12 } ,
1027+ ] ,
1028+ } ) ;
1029+
1030+ await loadApiKeys ( ) ;
1031+ // Allow the parallel stats fetch to resolve.
1032+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
1033+
1034+ const summary = document . getElementById ( 'apikeys-usage-summary' ) ;
1035+ expect ( summary ?. textContent ) . toContain ( 'Active keys' ) ;
1036+ expect ( summary ?. textContent ) . toContain ( '2' ) ;
1037+ expect ( summary ?. textContent ) . toContain ( 'Requests (24h)' ) ;
1038+ expect ( summary ?. textContent ) . toContain ( '42' ) ;
1039+ expect ( summary ?. textContent ) . toContain ( 'Requests (lifetime)' ) ;
1040+ // formatCount abbreviates large numbers in the summary tiles to
1041+ // fit the tile width — table cells keep the exact integer.
1042+ expect ( summary ?. textContent ) . toContain ( '1.2k' ) ;
1043+ expect ( summary ?. textContent ) . toContain ( 'Most active (24h)' ) ;
1044+ expect ( summary ?. textContent ) . toContain ( 'Busy' ) ;
1045+ expect ( summary ?. textContent ) . toContain ( 'aaaa1111' ) ;
1046+ } ) ;
1047+
1048+ test ( 'omits top-list when no key has 24h activity' , async ( ) => {
1049+ ( api . getApiKeys as jest . Mock ) . mockResolvedValue ( { api_keys : [ ] } ) ;
1050+ ( api . getApiKeysUsageStats as jest . Mock ) . mockResolvedValue ( {
1051+ total_active : 1 ,
1052+ total_requests_24h : 0 ,
1053+ total_requests_lifetime : 7 ,
1054+ top_keys : [ ] ,
1055+ } ) ;
1056+
1057+ await loadApiKeys ( ) ;
1058+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
1059+
1060+ const summary = document . getElementById ( 'apikeys-usage-summary' ) ;
1061+ expect ( summary ?. textContent ) . not . toContain ( 'Most active (24h)' ) ;
1062+ } ) ;
1063+
1064+ test ( 'shows inline error when stats endpoint fails, table still renders' , async ( ) => {
1065+ ( api . getApiKeys as jest . Mock ) . mockResolvedValue ( {
1066+ api_keys : [
1067+ { id : 'key-1' , name : 'k' , key_prefix : 'abc12345' , is_active : true , created_at : '2024-01-15T10:00:00Z' } ,
1068+ ] ,
1069+ } ) ;
1070+ ( api . getApiKeysUsageStats as jest . Mock ) . mockRejectedValue ( new Error ( 'boom' ) ) ;
1071+
1072+ await loadApiKeys ( ) ;
1073+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
1074+
1075+ const summary = document . getElementById ( 'apikeys-usage-summary' ) ;
1076+ expect ( summary ?. querySelector ( '.error' ) ?. textContent ) . toBe ( 'Failed to load usage summary' ) ;
1077+ // The table itself still renders despite the summary failure.
1078+ const list = document . getElementById ( 'apikeys-list' ) ;
1079+ expect ( list ?. innerHTML ) . toContain ( 'abc12345' ) ;
1080+ } ) ;
1081+
1082+ test ( 'renders Requests (24h) and Requests (total) columns per row' , async ( ) => {
1083+ ( api . getApiKeys as jest . Mock ) . mockResolvedValue ( {
1084+ api_keys : [
1085+ {
1086+ id : 'key-1' ,
1087+ name : 'k1' ,
1088+ key_prefix : 'abc12345' ,
1089+ is_active : true ,
1090+ created_at : '2024-01-15T10:00:00Z' ,
1091+ request_count_24h : 12 ,
1092+ request_count_total : 1234 ,
1093+ } ,
1094+ ] ,
1095+ } ) ;
1096+
1097+ await loadApiKeys ( ) ;
1098+
1099+ const container = document . getElementById ( 'apikeys-list' ) ;
1100+ expect ( container ?. innerHTML ) . toContain ( 'Requests (24h)' ) ;
1101+ expect ( container ?. innerHTML ) . toContain ( 'Requests (total)' ) ;
1102+ // Counts render with thousands separator for readability.
1103+ expect ( container ?. innerHTML ) . toContain ( '1,234' ) ;
1104+ expect ( container ?. innerHTML ) . toContain ( 'apikeys-count-cell' ) ;
1105+ } ) ;
1106+
1107+ test ( 'falls back to 0 when counter fields are missing from the response' , async ( ) => {
1108+ ( api . getApiKeys as jest . Mock ) . mockResolvedValue ( {
1109+ api_keys : [
1110+ // No request_count_* fields — simulates a cached older response.
1111+ { id : 'key-1' , name : 'k1' , key_prefix : 'abc12345' , is_active : true , created_at : '2024-01-15T10:00:00Z' } ,
1112+ ] ,
1113+ } ) ;
1114+
1115+ await loadApiKeys ( ) ;
1116+
1117+ const container = document . getElementById ( 'apikeys-list' ) ;
1118+ const cells = container ?. querySelectorAll ( '.apikeys-count-cell' ) ;
1119+ expect ( cells ?. length ) . toBe ( 2 ) ;
1120+ expect ( cells ?. [ 0 ] ?. textContent ) . toBe ( '0' ) ;
1121+ expect ( cells ?. [ 1 ] ?. textContent ) . toBe ( '0' ) ;
1122+ } ) ;
1123+
1124+ test ( 'list-fetch failure clears summary skeleton and shows error' , async ( ) => {
1125+ ( api . getApiKeys as jest . Mock ) . mockRejectedValue ( new Error ( 'list boom' ) ) ;
1126+ ( api . getApiKeysUsageStats as jest . Mock ) . mockResolvedValue ( {
1127+ total_active : 0 ,
1128+ total_requests_24h : 0 ,
1129+ total_requests_lifetime : 0 ,
1130+ top_keys : [ ] ,
1131+ } ) ;
1132+
1133+ await loadApiKeys ( ) ;
1134+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
1135+
1136+ const list = document . getElementById ( 'apikeys-list' ) ;
1137+ expect ( list ?. querySelector ( '.error' ) ?. textContent ) . toBe ( 'Failed to load API keys' ) ;
1138+ // The skeleton-active marker is gone (success/error both clear it).
1139+ expect ( list ?. dataset [ 'skeletonActive' ] ) . toBeUndefined ( ) ;
1140+ } ) ;
1141+ } ) ;
10031142} ) ;
0 commit comments