Skip to content

Commit ae85930

Browse files
authored
feat(statistics): calculate stats for any entity type using a view (#69)
closes #25
1 parent e1a5458 commit ae85930

File tree

8 files changed

+331
-102
lines changed

8 files changed

+331
-102
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ When generating code, please adhere to the following specific instructions:
200200
- If similar changes are required in multiple places, only implement in one place and ask for review before implementing
201201
elsewhere
202202
- If a change is complex or large, first suggest and approach broken into smaller parts and ask for review after each
203-
part
203+
part. Ask for confirmation after each part before proceeding with changes in Agent mode.
204204
- If unsure about a specific implementation detail, ask for clarification before proceeding
205205
- Do not change any code or tests that are unrelated to the direct task
206206
- Check the "Problems" tab in VSCode for TypeScript errors and any other issues after making changes before running

src/couchdb/couchdb-admin.controller.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,12 @@ describe('CouchdbAdminController', () => {
1414
{
1515
name: 'test1.example.com',
1616
users: 10,
17-
childrenTotal: 50,
18-
childrenActive: 45,
17+
entities: { Child: { all: 50, active: 45 }, User: { all: 5, active: 3 } },
1918
},
2019
{
2120
name: 'test2.example.com',
2221
users: 5,
23-
childrenTotal: 30,
24-
childrenActive: 28,
22+
entities: { Child: { all: 30, active: 28 } },
2523
},
2624
];
2725

@@ -90,7 +88,9 @@ describe('CouchdbAdminController', () => {
9088
'inline; filename="statistics.csv"',
9189
);
9290
expect(mockResponse.send).toHaveBeenCalledWith(
93-
expect.stringContaining('name,users,childrenTotal,childrenActive'),
91+
expect.stringContaining(
92+
'name,users,Child_all,Child_active,User_all,User_active',
93+
),
9494
);
9595
});
9696

src/couchdb/couchdb-admin.controller.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,24 @@ export class CouchdbAdminController {
179179
if (format === 'csv') {
180180
res.setHeader('Content-Type', 'text/csv');
181181
res.setHeader('Content-Disposition', 'inline; filename="statistics.csv"');
182-
res.send(Papa.unparse(statisticsData));
182+
183+
// replace statistics.entities (`{ Child: { all: 30, active: 25 }, User: { all: 5, active: 3 } }`)
184+
// and add flattened properties for each entity type and status to the statistics object
185+
// (e.g. `Child_all: 30, Child_active: 25, User_all: 5, User_active: 3`)
186+
const flattenedData = statisticsData.map((stat) => {
187+
const flattenedEntities = {};
188+
for (const [entityType, counts] of Object.entries(stat.entities)) {
189+
flattenedEntities[`${entityType}_all`] = counts.all;
190+
flattenedEntities[`${entityType}_active`] = counts.active;
191+
}
192+
return {
193+
name: stat.name,
194+
users: stat.users,
195+
...flattenedEntities,
196+
};
197+
});
198+
199+
res.send(Papa.unparse(flattenedData));
183200
} else {
184201
res.json(statisticsData);
185202
}

src/couchdb/couchdb.service.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ export class Couchdb {
5959
this.baseUrl = `https://${this.url}/db`;
6060
}
6161

62-
get(path: string) {
62+
get<R = any>(path: string, db?: string): Promise<R> {
63+
if (!path.startsWith('/')) {
64+
path = '/' + path;
65+
}
66+
if (db) {
67+
path = `/${db}${path}`;
68+
}
69+
6370
const httpConfig = { auth: this.auth };
6471
return firstValueFrom(
6572
this.http.get(`${this.baseUrl}/couchdb${path}`, httpConfig).pipe(
@@ -104,7 +111,14 @@ export class Couchdb {
104111
);
105112
}
106113

107-
put(path: string, data, headers?: any): Promise<any> {
114+
put(path: string, data, db?: string, headers?: any): Promise<any> {
115+
if (!path.startsWith('/')) {
116+
path = '/' + path;
117+
}
118+
if (db) {
119+
path = `/${db}${path}`;
120+
}
121+
108122
return firstValueFrom(
109123
this.http
110124
.put(`${this.baseUrl}/couchdb${path}`, data, {
@@ -121,6 +135,10 @@ export class Couchdb {
121135
}
122136

123137
post(path: string, data, headers?: any): Promise<any> {
138+
if (!path.startsWith('/')) {
139+
path = '/' + path;
140+
}
141+
124142
return firstValueFrom(
125143
this.http
126144
.post(`${this.baseUrl}/couchdb${path}`, data, {
@@ -135,4 +153,8 @@ export class Couchdb {
135153
),
136154
);
137155
}
156+
157+
find(query, db = 'app'): Promise<any[]> {
158+
return this.post(`/${db}/_find`, query);
159+
}
138160
}

src/couchdb/statistics/statistics.service.spec.ts

Lines changed: 191 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { StatisticsService } from './statistics.service';
3-
import { CouchdbService } from '../couchdb.service';
3+
import { Couchdb, CouchdbService } from '../couchdb.service';
44
import { KeycloakService } from '../../keycloak/keycloak.service';
55
import {
66
CredentialsService,
77
SystemCredentials,
88
} from '../../credentials/credentials.service';
9+
import { HttpService } from '@nestjs/axios';
910

1011
describe('StatisticsService', () => {
1112
let service: StatisticsService;
1213
let mockCouchdbService: jest.Mocked<CouchdbService>;
1314
let mockKeycloakService: jest.Mocked<KeycloakService>;
1415
let mockCredentialsService: jest.Mocked<CredentialsService>;
16+
let mockCouchdbInstance: jest.Mocked<Couchdb>;
1517

1618
beforeEach(async () => {
19+
// Create a mock Couchdb instance
20+
mockCouchdbInstance = {
21+
url: 'org1.example.com',
22+
get: jest.fn(),
23+
put: jest.fn(),
24+
} as any;
25+
1726
const mockCouchdb = {
18-
runForAllOrgs: jest.fn(),
27+
getCouchdb: jest.fn().mockReturnValue(mockCouchdbInstance),
28+
runForAllOrgs: jest.fn(), // We'll replace this with the real implementation
1929
};
2030

2131
const mockKeycloak = {
@@ -40,44 +50,208 @@ describe('StatisticsService', () => {
4050
mockCouchdbService = module.get(CouchdbService);
4151
mockKeycloakService = module.get(KeycloakService);
4252
mockCredentialsService = module.get(CredentialsService);
53+
54+
// Create real CouchdbService instance to get the real runForAllOrgs implementation
55+
// but bind it to our mocked service so it uses our mocked getCouchdb method
56+
const realCouchdbService = new CouchdbService({} as HttpService);
57+
mockCouchdbService.runForAllOrgs =
58+
realCouchdbService.runForAllOrgs.bind(mockCouchdbService);
59+
60+
// Mock the createOrUpdateStatisticsView method to avoid the undefined error
61+
jest
62+
.spyOn(service as any, 'createOrUpdateStatisticsView')
63+
.mockResolvedValue(undefined);
4364
});
4465

4566
it('should be defined', () => {
4667
expect(service).toBeDefined();
4768
});
4869

49-
it('should get statistics from all organizations', async () => {
70+
it('should get statistics from all organizations using CouchDB views', async () => {
5071
const mockToken = 'mock-token';
5172
const mockCredentials: SystemCredentials[] = [
5273
{ url: 'org1.example.com', password: 'password1' },
5374
];
54-
const mockResults = {
55-
'org1.example.com': {
56-
name: 'org1.example.com',
57-
users: 10,
58-
childrenTotal: 50,
59-
childrenActive: 45,
60-
},
61-
};
75+
const mockUsers = Array(10)
76+
.fill({})
77+
.map((_, i) => ({ id: `user${i}` }));
78+
79+
// Mock CouchDB view responses
80+
const mockStatsAll = [
81+
{ key: 'Child', value: 50 },
82+
{ key: 'User', value: 10 },
83+
{ key: 'School', value: 5 },
84+
];
85+
86+
const mockStatsActive = [
87+
{ key: 'Child', value: 45 },
88+
{ key: 'User', value: 8 },
89+
{ key: 'School', value: 5 },
90+
];
6291

6392
mockKeycloakService.getKeycloakToken.mockResolvedValue(mockToken);
6493
mockCredentialsService.getCredentials.mockReturnValue(mockCredentials);
65-
mockCouchdbService.runForAllOrgs.mockResolvedValue(mockResults);
94+
mockKeycloakService.getUsersFromKeycloak.mockResolvedValue(
95+
mockUsers as any,
96+
);
97+
98+
// Mock the CouchDB get calls for the statistics views
99+
mockCouchdbInstance.get
100+
.mockResolvedValueOnce(mockStatsAll) // entities_all view
101+
.mockResolvedValueOnce(mockStatsActive); // entities_active view
66102

67103
const result = await service.getStatistics();
68104

105+
// Verify the sequence of operations
69106
expect(mockKeycloakService.getKeycloakToken).toHaveBeenCalled();
70107
expect(mockCredentialsService.getCredentials).toHaveBeenCalled();
71-
expect(mockCouchdbService.runForAllOrgs).toHaveBeenCalledWith(
72-
mockCredentials,
73-
expect.any(Function),
108+
expect(mockCouchdbService.getCouchdb).toHaveBeenCalledWith(
109+
'org1.example.com',
110+
'password1',
111+
);
112+
expect(mockKeycloakService.getUsersFromKeycloak).toHaveBeenCalledWith(
113+
'org1',
114+
mockToken,
115+
);
116+
117+
// Verify view queries
118+
expect(mockCouchdbInstance.get).toHaveBeenCalledWith(
119+
'_design/statistics/_view/entities_all?group=true',
120+
'app',
74121
);
122+
expect(mockCouchdbInstance.get).toHaveBeenCalledWith(
123+
'_design/statistics/_view/entities_active?group=true',
124+
'app',
125+
);
126+
127+
// Verify result structure
75128
expect(result).toEqual([
76129
{
77130
name: 'org1.example.com',
78131
users: 10,
79-
childrenTotal: 50,
80-
childrenActive: 45,
132+
entities: {
133+
Child: { all: 50, active: 45 },
134+
User: { all: 10, active: 8 },
135+
School: { all: 5, active: 5 },
136+
},
137+
},
138+
]);
139+
});
140+
141+
it('should handle keycloak failure and fallback to empty users array', async () => {
142+
const mockToken = 'mock-token';
143+
const mockCredentials: SystemCredentials[] = [
144+
{ url: 'org1.example.com', password: 'password1' },
145+
];
146+
147+
const mockStatsAll = [
148+
{ key: 'Child', value: 30 },
149+
{ key: 'User', value: 5 },
150+
];
151+
152+
const mockStatsActive = [
153+
{ key: 'Child', value: 25 },
154+
{ key: 'User', value: 3 },
155+
];
156+
157+
mockKeycloakService.getKeycloakToken.mockResolvedValue(mockToken);
158+
mockCredentialsService.getCredentials.mockReturnValue(mockCredentials);
159+
mockKeycloakService.getUsersFromKeycloak.mockRejectedValue(
160+
new Error('Keycloak error'),
161+
);
162+
163+
mockCouchdbInstance.get
164+
.mockResolvedValueOnce(mockStatsAll)
165+
.mockResolvedValueOnce(mockStatsActive);
166+
167+
const result = await service.getStatistics();
168+
169+
expect(mockKeycloakService.getUsersFromKeycloak).toHaveBeenCalledWith(
170+
'org1',
171+
mockToken,
172+
);
173+
174+
// When Keycloak fails, should use empty users array (length 0)
175+
expect(result).toEqual([
176+
{
177+
name: 'org1.example.com',
178+
users: 0,
179+
entities: {
180+
Child: { all: 30, active: 25 },
181+
User: { all: 5, active: 3 },
182+
},
183+
},
184+
]);
185+
});
186+
187+
it('should handle CouchDB view failures gracefully', async () => {
188+
const mockToken = 'mock-token';
189+
const mockCredentials: SystemCredentials[] = [
190+
{ url: 'org1.example.com', password: 'password1' },
191+
];
192+
const mockUsers = Array(5)
193+
.fill({})
194+
.map((_, i) => ({ id: `user${i}` }));
195+
196+
mockKeycloakService.getKeycloakToken.mockResolvedValue(mockToken);
197+
mockCredentialsService.getCredentials.mockReturnValue(mockCredentials);
198+
mockKeycloakService.getUsersFromKeycloak.mockResolvedValue(
199+
mockUsers as any,
200+
);
201+
202+
// Mock CouchDB view failures
203+
mockCouchdbInstance.get
204+
.mockRejectedValueOnce(new Error('View error')) // entities_all view fails
205+
.mockRejectedValueOnce(new Error('View error')); // entities_active view fails
206+
207+
const result = await service.getStatistics();
208+
209+
expect(result).toEqual([
210+
{
211+
name: 'org1.example.com',
212+
users: 5,
213+
entities: {}, // Empty entities when both views fail
214+
},
215+
]);
216+
});
217+
218+
it('should handle partial view data correctly', async () => {
219+
const mockToken = 'mock-token';
220+
const mockCredentials: SystemCredentials[] = [
221+
{ url: 'org1.example.com', password: 'password1' },
222+
];
223+
const mockUsers = Array(3)
224+
.fill({})
225+
.map((_, i) => ({ id: `user${i}` }));
226+
227+
// Mock data where active has entities not in all (edge case)
228+
const mockStatsAll = [{ key: 'Child', value: 20 }];
229+
230+
const mockStatsActive = [
231+
{ key: 'Child', value: 18 },
232+
{ key: 'Note', value: 5 }, // Note exists in active but not in all
233+
];
234+
235+
mockKeycloakService.getKeycloakToken.mockResolvedValue(mockToken);
236+
mockCredentialsService.getCredentials.mockReturnValue(mockCredentials);
237+
mockKeycloakService.getUsersFromKeycloak.mockResolvedValue(
238+
mockUsers as any,
239+
);
240+
241+
mockCouchdbInstance.get
242+
.mockResolvedValueOnce(mockStatsAll)
243+
.mockResolvedValueOnce(mockStatsActive);
244+
245+
const result = await service.getStatistics();
246+
247+
expect(result).toEqual([
248+
{
249+
name: 'org1.example.com',
250+
users: 3,
251+
entities: {
252+
Child: { all: 20, active: 18 },
253+
Note: { all: 0, active: 5 }, // Handles entity in active but not in all
254+
},
81255
},
82256
]);
83257
});

0 commit comments

Comments
 (0)