Skip to content

Commit c56bb79

Browse files
gino-mrfontanarosa
andauthored
[Export CSV] Export data stored in new proto-based format (#1917)
Co-authored-by: Roberto Fontanarosa <[email protected]>
1 parent df6d8e2 commit c56bb79

15 files changed

+581
-115
lines changed

e2e-tests/.eslintrc.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"rules": {
44
"node/no-unpublished-import": ["error", {
55
"allowModules": ["jasmine"]
6-
}]
6+
}],
7+
"eqeqeq": ["error", "always", {"null": "ignore"}]
78
}
89
}

functions/.eslintrc.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,8 @@
66
"parserOptions": {
77
"sourceType": "module"
88
},
9-
"root": true
9+
"root": true,
10+
"rules": {
11+
"eqeqeq": ["error", "always", {"null": "ignore"}]
12+
}
1013
}

functions/src/export-csv.spec.ts

+292
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
/**
2+
* Copyright 2024 The Ground Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
createMockFirestore,
19+
stubAdminApi,
20+
} from '@ground/lib/dist/testing/firestore';
21+
import {
22+
createGetRequestSpy,
23+
createResponseSpy,
24+
} from './testing/http-test-helpers';
25+
import {DecodedIdToken} from 'firebase-admin/auth';
26+
import HttpStatus from 'http-status-codes';
27+
import {OWNER_ROLE} from './common/auth';
28+
import {resetDatastore} from './common/context';
29+
import {Firestore} from 'firebase-admin/firestore';
30+
import {exportCsvHandler} from './export-csv';
31+
import {registry} from '@ground/lib';
32+
import {GroundProtos} from '@ground/proto';
33+
34+
import Pb = GroundProtos.ground.v1beta1;
35+
const l = registry.getFieldIds(Pb.LocationOfInterest);
36+
const pr = registry.getFieldIds(Pb.LocationOfInterest.Property);
37+
const p = registry.getFieldIds(Pb.Point);
38+
const c = registry.getFieldIds(Pb.Coordinates);
39+
const g = registry.getFieldIds(Pb.Geometry);
40+
const s = registry.getFieldIds(Pb.Submission);
41+
const d = registry.getFieldIds(Pb.TaskData);
42+
const cl = registry.getFieldIds(Pb.TaskData.CaptureLocationResult);
43+
44+
describe('exportCsv()', () => {
45+
let mockFirestore: Firestore;
46+
const jobId = 'job123';
47+
const email = '[email protected]';
48+
const userId = 'user5000';
49+
// TODO(#1758): Use new proto-based survey and job representation.
50+
const survey1 = {
51+
id: 'survey001',
52+
name: 'Test survey 1',
53+
acl: {
54+
[email]: OWNER_ROLE,
55+
},
56+
};
57+
const survey2 = {
58+
id: 'survey002',
59+
name: 'Test survey 2',
60+
acl: {
61+
[email]: OWNER_ROLE,
62+
},
63+
jobs: {
64+
[jobId]: {
65+
name: 'Test job',
66+
tasks: {
67+
task001: {
68+
type: 'text_field',
69+
label: 'What is the meaning of life?',
70+
},
71+
task002: {
72+
type: 'number_field',
73+
label: 'How much?',
74+
},
75+
task003: {
76+
type: 'date_time_field',
77+
label: 'When?',
78+
},
79+
task004: {
80+
type: 'select_multiple',
81+
label: 'Which ones?',
82+
options: [
83+
{
84+
id: 'aaa',
85+
label: 'AAA',
86+
},
87+
{
88+
id: 'bbb',
89+
label: 'BBB',
90+
},
91+
],
92+
hasOtherOption: true,
93+
},
94+
task005: {
95+
type: 'capture_location',
96+
label: 'Where are you now?',
97+
},
98+
task006: {
99+
type: 'take_photo',
100+
label: 'Take a photo',
101+
},
102+
},
103+
},
104+
},
105+
};
106+
const pointLoi1 = {
107+
id: 'loi100',
108+
[l.jobId]: jobId,
109+
[l.customTag]: 'POINT_001',
110+
[l.geometry]: {
111+
[g.point]: {[p.coordinates]: {[c.latitude]: 10.1, [c.longitude]: 125.6}},
112+
},
113+
[l.submission_count]: 0,
114+
[l.source]: Pb.LocationOfInterest.Source.IMPORTED,
115+
[l.properties]: {
116+
name: {[pr.stringValue]: 'Dinagat Islands'},
117+
area: {[pr.numericValue]: 3.08},
118+
},
119+
};
120+
const pointLoi2 = {
121+
id: 'loi200',
122+
[l.jobId]: jobId,
123+
[l.customTag]: 'POINT_002',
124+
[l.geometry]: {
125+
[g.point]: {[p.coordinates]: {[c.latitude]: 47.05, [c.longitude]: 8.3}},
126+
},
127+
[l.submissionCount]: 0,
128+
[l.source]: Pb.LocationOfInterest.Source.FIELD_DATA,
129+
[l.properties]: {
130+
name: {[pr.stringValue]: 'Luzern'},
131+
},
132+
};
133+
const submission1a = {
134+
id: '001a',
135+
[s.loiId]: pointLoi1.id,
136+
[s.index]: 1,
137+
[s.jobId]: jobId,
138+
[s.ownerId]: userId,
139+
[s.taskData]: [
140+
{
141+
[d.id]: 'data001a',
142+
[d.taskId]: 'task001',
143+
[d.textResponse]: {
144+
'1': 'Submission 1',
145+
},
146+
},
147+
{
148+
[d.id]: 'data002a',
149+
[d.taskId]: 'task002',
150+
[d.numberResponse]: {
151+
'1': 42,
152+
},
153+
},
154+
],
155+
};
156+
const submission1b = {
157+
id: '001b',
158+
[s.loiId]: pointLoi1.id,
159+
[s.index]: 2,
160+
[s.jobId]: jobId,
161+
[s.ownerId]: userId,
162+
[s.taskData]: [
163+
{
164+
[d.id]: 'data001b',
165+
[d.taskId]: 'task001',
166+
[d.textResponse]: {
167+
'1': 'Submission 2',
168+
},
169+
},
170+
{
171+
[d.id]: 'data003a',
172+
[d.taskId]: 'task003',
173+
[d.dateTimeResponse]: {
174+
'1': {
175+
'1': 1331209044, // seconds
176+
},
177+
},
178+
},
179+
],
180+
};
181+
const submission2a = {
182+
id: '002a',
183+
[s.loiId]: pointLoi2.id,
184+
[s.index]: 1,
185+
[s.jobId]: jobId,
186+
[s.ownerId]: userId,
187+
[s.taskData]: [
188+
{
189+
[d.id]: 'data004',
190+
[d.taskId]: 'task004',
191+
[d.multipleChoiceResponses]: {
192+
'1': ['aaa', 'bbb'],
193+
'2': 'Other',
194+
},
195+
},
196+
{
197+
[d.id]: 'data005a',
198+
[d.taskId]: 'task005',
199+
[d.captureLocationResult]: {
200+
[cl.coordinates]: {
201+
[c.latitude]: -123,
202+
[c.longitude]: 45,
203+
},
204+
},
205+
},
206+
{
207+
[d.id]: 'data006b',
208+
[d.taskId]: 'task006',
209+
[d.takePhotoResult]: {
210+
'1': 'http://photo/url',
211+
},
212+
},
213+
],
214+
};
215+
const testCases = [
216+
{
217+
desc: 'export points w/o submissions',
218+
survey: survey1,
219+
lois: [pointLoi1, pointLoi2],
220+
submissions: [],
221+
expectedFilename: 'ground-export.csv',
222+
expectedCsv: [
223+
'"system:index","geometry","name","area","data:contributor_name","data:contributor_email"',
224+
'"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,,',
225+
'"POINT_002","POINT (8.3 47.05)","Luzern",,,',
226+
],
227+
},
228+
{
229+
desc: 'export points w/submissions',
230+
survey: survey2,
231+
lois: [pointLoi1, pointLoi2],
232+
submissions: [submission1a, submission1b, submission2a],
233+
expectedFilename: 'test-job.csv',
234+
expectedCsv: [
235+
'"system:index","geometry","name","area","data:What is the meaning of life?","data:How much?","data:When?","data:Which ones?","data:Where are you now?","data:Take a photo","data:contributor_name","data:contributor_email"',
236+
'"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 1",42,,,,,,',
237+
'"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 2",,"2012-03-08T12:17:24.000Z",,,,,',
238+
'"POINT_002","POINT (8.3 47.05)","Luzern",,,,,"AAA,BBB,Other","POINT (45 -123)","http://photo/url",,',
239+
],
240+
},
241+
];
242+
243+
beforeEach(() => {
244+
mockFirestore = createMockFirestore();
245+
stubAdminApi(mockFirestore);
246+
});
247+
248+
afterEach(() => {
249+
resetDatastore();
250+
});
251+
252+
testCases.forEach(
253+
({desc, survey, lois, submissions, expectedFilename, expectedCsv}) =>
254+
it(desc, async () => {
255+
// Populate database.
256+
mockFirestore.doc(`surveys/${survey.id}`).set(survey);
257+
lois?.forEach(({id, ...loi}) =>
258+
mockFirestore.doc(`surveys/${survey.id}/lois/${id}`).set(loi)
259+
);
260+
submissions?.forEach(({id, ...submission}) =>
261+
mockFirestore
262+
.doc(`surveys/${survey.id}/submissions/${id}`)
263+
.set(submission)
264+
);
265+
266+
// Build mock request and response.
267+
const req = await createGetRequestSpy({
268+
url: '/exportCsv',
269+
query: {
270+
survey: survey.id,
271+
job: jobId,
272+
},
273+
});
274+
const chunks: string[] = [];
275+
const res = createResponseSpy(chunks);
276+
277+
// Run export CSV handler.
278+
await exportCsvHandler(req, res, {email} as DecodedIdToken);
279+
280+
// Check post-conditions.
281+
expect(res.status).toHaveBeenCalledOnceWith(HttpStatus.OK);
282+
expect(res.type).toHaveBeenCalledOnceWith('text/csv');
283+
expect(res.setHeader).toHaveBeenCalledOnceWith(
284+
'Content-Disposition',
285+
`attachment; filename=${expectedFilename}`
286+
);
287+
const output = chunks.join('').trim();
288+
const lines = output.split('\n');
289+
expect(lines).toEqual(expectedCsv);
290+
})
291+
);
292+
});

0 commit comments

Comments
 (0)