Skip to content

Commit b452085

Browse files
authored
Merge pull request #147 from wri/feat/TM-1958-data-api-gadm
[TM-1958] GADM codes for countries / states / districts
2 parents 0a5ea21 + 3af1589 commit b452085

28 files changed

+840
-65
lines changed

.env.local.sample

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ DB_PASSWORD=wri
1515
REDIS_HOST=localhost
1616
REDIS_PORT=6379
1717

18+
# Shared Data API key that may be used by all devs and AWS environments
19+
DATA_API_KEY=349c85f6-a915-4d3d-b337-0a0dafc0e5db
20+
1821
JWT_SECRET=qu3sep4GKdbg6PiVPCKLKljHukXALorq6nLHDBOCSwvs6BrgE6zb8gPmZfrNspKt
1922

2023
AWS_ENDPOINT==http://minio:9000

apps/unified-database-service/src/airtable/airtable.module.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ import { BullModule } from "@nestjs/bullmq";
44
import { Module } from "@nestjs/common";
55
import { AirtableService } from "./airtable.service";
66
import { AirtableProcessor } from "./airtable.processor";
7+
import { DataApiModule } from "@terramatch-microservices/data-api";
78

89
@Module({
9-
imports: [CommonModule, ConfigModule.forRoot({ isGlobal: true }), BullModule.registerQueue({ name: "airtable" })],
10+
imports: [
11+
CommonModule,
12+
ConfigModule.forRoot({ isGlobal: true }),
13+
BullModule.registerQueue({ name: "airtable" }),
14+
DataApiModule
15+
],
1016
providers: [AirtableService, AirtableProcessor],
1117
exports: [AirtableService]
1218
})

apps/unified-database-service/src/airtable/airtable.processor.spec.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createMock } from "@golevelup/ts-jest";
55
import { InternalServerErrorException, NotImplementedException } from "@nestjs/common";
66
import { Job } from "bullmq";
77
import { SlackService } from "@terramatch-microservices/common/slack/slack.service";
8+
import { DataApiService } from "@terramatch-microservices/data-api";
89

910
jest.mock("airtable", () =>
1011
jest.fn(() => ({
@@ -20,7 +21,8 @@ describe("AirtableProcessor", () => {
2021
providers: [
2122
AirtableProcessor,
2223
{ provide: ConfigService, useValue: createMock<ConfigService>() },
23-
{ provide: SlackService, useValue: createMock<SlackService>() }
24+
{ provide: SlackService, useValue: createMock<SlackService>() },
25+
{ provide: DataApiService, useValue: createMock<DataApiService>() }
2426
]
2527
}).compile();
2628

apps/unified-database-service/src/airtable/airtable.processor.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import * as Sentry from "@sentry/node";
2222
import { SlackService } from "@terramatch-microservices/common/slack/slack.service";
2323
import { TMLogger } from "@terramatch-microservices/common/util/tm-logger";
24+
import { DataApiService } from "@terramatch-microservices/data-api";
2425

2526
export const AIRTABLE_ENTITIES = {
2627
applications: ApplicationEntity,
@@ -66,7 +67,11 @@ export class AirtableProcessor extends WorkerHost {
6667
private readonly logger = new TMLogger(AirtableProcessor.name);
6768
private readonly base: Airtable.Base;
6869

69-
constructor(private readonly config: ConfigService, private readonly slack: SlackService) {
70+
constructor(
71+
private readonly config: ConfigService,
72+
private readonly slack: SlackService,
73+
private readonly dataApi: DataApiService
74+
) {
7075
super();
7176
this.base = new Airtable({ apiKey: this.config.get("AIRTABLE_API_KEY") }).base(this.config.get("AIRTABLE_BASE_ID"));
7277
}
@@ -104,7 +109,7 @@ export class AirtableProcessor extends WorkerHost {
104109
throw new InternalServerErrorException(`Entity mapping not found for entity type ${entityType}`);
105110
}
106111

107-
const entity = new entityClass();
112+
const entity = new entityClass(this.dataApi);
108113
await entity.updateBase(this.base, { startPage, updatedSince });
109114

110115
this.logger.log(`Completed entity update: ${JSON.stringify({ entityType, updatedSince })}`);
@@ -119,7 +124,7 @@ export class AirtableProcessor extends WorkerHost {
119124
throw new InternalServerErrorException(`Entity mapping not found for entity type ${entityType}`);
120125
}
121126

122-
const entity = new entityClass();
127+
const entity = new entityClass(this.dataApi);
123128
await entity.deleteStaleRecords(this.base, deletedSince);
124129

125130
this.logger.log(`Completed entity delete: ${JSON.stringify({ entityType, deletedSince })}`);

apps/unified-database-service/src/airtable/airtable.service.spec.ts

+5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AirtableService } from "./airtable.service";
33
import { Queue } from "bullmq";
44
import { Test } from "@nestjs/testing";
55
import { getQueueToken } from "@nestjs/bullmq";
6+
import { DataApiService } from "@terramatch-microservices/data-api";
67

78
describe("AirtableService", () => {
89
let service: AirtableService;
@@ -15,6 +16,10 @@ describe("AirtableService", () => {
1516
{
1617
provide: getQueueToken("airtable"),
1718
useValue: (queue = createMock<Queue>())
19+
},
20+
{
21+
provide: DataApiService,
22+
useValue: createMock<DataApiService>()
1823
}
1924
]
2025
}).compile();

apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts

+68-19
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
Demographic,
66
DemographicEntry,
77
Framework,
8+
FundingProgramme,
89
Nursery,
910
NurseryReport,
1011
Organisation,
@@ -39,10 +40,12 @@ import {
3940
ApplicationEntity,
4041
DemographicEntity,
4142
DemographicEntryEntity,
43+
FundingProgrammeEntity,
4244
NurseryEntity,
4345
NurseryReportEntity,
4446
OrganisationEntity,
4547
ProjectEntity,
48+
ProjectPitchEntity,
4649
ProjectReportEntity,
4750
SiteEntity,
4851
SiteReportEntity,
@@ -53,6 +56,14 @@ import { Model } from "sequelize-typescript";
5356
import { FrameworkKey } from "@terramatch-microservices/database/constants/framework";
5457
import { FindOptions, Op } from "sequelize";
5558
import { DateTime } from "luxon";
59+
import { DataApiService } from "@terramatch-microservices/data-api";
60+
import { createMock } from "@golevelup/ts-jest";
61+
import {
62+
COUNTRIES,
63+
gadmLevel0Mock,
64+
gadmLevel1Mock,
65+
STATES
66+
} from "@terramatch-microservices/database/util/gadm-mock-data";
5667

5768
const airtableUpdate = jest.fn<Promise<unknown>, [{ fields: object }[], object]>(() => Promise.resolve());
5869
const airtableSelectFirstPage = jest.fn<Promise<unknown>, never>(() => Promise.resolve([]));
@@ -64,6 +75,8 @@ const Base = jest.fn(() => ({
6475
destroy: airtableDestroy
6576
})) as unknown as Airtable.Base;
6677

78+
const dataApi = createMock<DataApiService>({ gadmLevel0: gadmLevel0Mock, gadmLevel1: gadmLevel1Mock });
79+
6780
const mapEntityColumns = jest.fn(() => Promise.resolve({}));
6881
export class StubEntity extends AirtableEntity<Site> {
6982
readonly TABLE_NAME = "stubs";
@@ -121,18 +134,18 @@ describe("AirtableEntity", () => {
121134

122135
it("re-raises mapping errors", async () => {
123136
mapEntityColumns.mockRejectedValue(new Error("mapping error"));
124-
await expect(new StubEntity().updateBase(null, { startPage: 0 })).rejects.toThrow("mapping error");
137+
await expect(new StubEntity(dataApi).updateBase(null, { startPage: 0 })).rejects.toThrow("mapping error");
125138
mapEntityColumns.mockReset();
126139
});
127140

128141
it("re-raises airtable errors", async () => {
129142
airtableUpdate.mockRejectedValue(new Error("airtable error"));
130-
await expect(new StubEntity().updateBase(Base)).rejects.toThrow("airtable error");
143+
await expect(new StubEntity(dataApi).updateBase(Base)).rejects.toThrow("airtable error");
131144
airtableUpdate.mockReset();
132145
});
133146

134147
it("includes the updatedSince timestamp in the query", async () => {
135-
const entity = new StubEntity();
148+
const entity = new StubEntity(dataApi);
136149
const spy = jest.spyOn(entity as never, "getUpdatePageFindOptions") as jest.SpyInstance<FindOptions<Site>>;
137150
const updatedSince = new Date();
138151
await entity.updateBase(Base, { updatedSince });
@@ -143,7 +156,7 @@ describe("AirtableEntity", () => {
143156
});
144157

145158
it("skips the updatedSince timestamp if the model doesn't support it", async () => {
146-
const entity = new StubEntity();
159+
const entity = new StubEntity(dataApi);
147160
// @ts-expect-error overriding readonly property for test.
148161
(entity as never).SUPPORTS_UPDATED_SINCE = false;
149162
const spy = jest.spyOn(entity as never, "getUpdatePageFindOptions") as jest.SpyInstance<FindOptions<Site>>;
@@ -180,13 +193,13 @@ describe("AirtableEntity", () => {
180193

181194
it("re-raises search errors", async () => {
182195
airtableSelectFirstPage.mockRejectedValue(new Error("select error"));
183-
await expect(new SiteEntity().deleteStaleRecords(Base, deletedSince)).rejects.toThrow("select error");
196+
await expect(new SiteEntity(dataApi).deleteStaleRecords(Base, deletedSince)).rejects.toThrow("select error");
184197
});
185198

186199
it("re-raises delete errors", async () => {
187200
airtableSelectFirstPage.mockResolvedValue([{ id: "fakeid", fields: { uuid: "fakeuuid" } }]);
188201
airtableDestroy.mockRejectedValue(new Error("delete error"));
189-
await expect(new SiteEntity().deleteStaleRecords(Base, deletedSince)).rejects.toThrow("delete error");
202+
await expect(new SiteEntity(dataApi).deleteStaleRecords(Base, deletedSince)).rejects.toThrow("delete error");
190203
airtableDestroy.mockReset();
191204
});
192205

@@ -196,7 +209,7 @@ describe("AirtableEntity", () => {
196209
fields: { uuid }
197210
}));
198211
airtableSelectFirstPage.mockResolvedValue(searchResult);
199-
await new SiteEntity().deleteStaleRecords(Base, deletedSince);
212+
await new SiteEntity(dataApi).deleteStaleRecords(Base, deletedSince);
200213
expect(airtableSelect).toHaveBeenCalledTimes(1);
201214
expect(airtableSelect).toHaveBeenCalledWith(
202215
expect.objectContaining({
@@ -254,7 +267,7 @@ describe("AirtableEntity", () => {
254267

255268
it("sends all records to airtable", async () => {
256269
await testAirtableUpdates(
257-
new ApplicationEntity(),
270+
new ApplicationEntity(dataApi),
258271
applications,
259272
({ uuid, organisationUuid, fundingProgrammeUuid }) => ({
260273
fields: {
@@ -327,7 +340,7 @@ describe("AirtableEntity", () => {
327340

328341
it("sends all records to airtable", async () => {
329342
await testAirtableUpdates(
330-
new DemographicEntity(),
343+
new DemographicEntity(dataApi),
331344
demographics,
332345
({ uuid, collection, demographicalType, demographicalId }) => ({
333346
fields: {
@@ -385,7 +398,7 @@ describe("AirtableEntity", () => {
385398

386399
it("sends all records to airtable", async () => {
387400
await testAirtableUpdates(
388-
new DemographicEntryEntity(),
401+
new DemographicEntryEntity(dataApi),
389402
entries,
390403
({ id, type, subtype, name, amount, demographicId }) => ({
391404
fields: {
@@ -401,6 +414,21 @@ describe("AirtableEntity", () => {
401414
});
402415
});
403416

417+
describe("FundingProgrammeEntity", () => {
418+
let fundingProgrammes: FundingProgramme[];
419+
420+
beforeAll(async () => {
421+
await FundingProgramme.truncate();
422+
fundingProgrammes = await FundingProgrammeFactory.createMany(2);
423+
});
424+
425+
it("sends all records to airtable", async () => {
426+
await testAirtableUpdates(new FundingProgrammeEntity(dataApi), fundingProgrammes, ({ uuid, name }) => ({
427+
fields: { uuid, name }
428+
}));
429+
});
430+
});
431+
404432
describe("NurseryEntity", () => {
405433
let projectUuids: Record<number, string>;
406434
let nurseries: Nursery[];
@@ -422,7 +450,7 @@ describe("AirtableEntity", () => {
422450
});
423451

424452
it("sends all records to airtable", async () => {
425-
await testAirtableUpdates(new NurseryEntity(), nurseries, ({ uuid, name, projectId, status }) => ({
453+
await testAirtableUpdates(new NurseryEntity(dataApi), nurseries, ({ uuid, name, projectId, status }) => ({
426454
fields: {
427455
uuid,
428456
name,
@@ -454,7 +482,7 @@ describe("AirtableEntity", () => {
454482
});
455483

456484
it("sends all records to airtable", async () => {
457-
await testAirtableUpdates(new NurseryReportEntity(), reports, ({ uuid, nurseryId, status, dueAt }) => ({
485+
await testAirtableUpdates(new NurseryReportEntity(dataApi), reports, ({ uuid, nurseryId, status, dueAt }) => ({
458486
fields: {
459487
uuid,
460488
nurseryUuid: nurseryUuids[nurseryId],
@@ -479,7 +507,7 @@ describe("AirtableEntity", () => {
479507
});
480508

481509
it("sends all records to airtable", async () => {
482-
await testAirtableUpdates(new OrganisationEntity(), organisations, ({ uuid, name, status }) => ({
510+
await testAirtableUpdates(new OrganisationEntity(dataApi), organisations, ({ uuid, name, status }) => ({
483511
fields: {
484512
uuid,
485513
name,
@@ -574,7 +602,7 @@ describe("AirtableEntity", () => {
574602

575603
it("sends all records to airtable", async () => {
576604
await testAirtableUpdates(
577-
new ProjectEntity(),
605+
new ProjectEntity(dataApi),
578606
projects,
579607
({ uuid, name, frameworkKey, organisationId, applicationId }) => ({
580608
fields: {
@@ -590,6 +618,27 @@ describe("AirtableEntity", () => {
590618
});
591619
});
592620

621+
describe("ProjectPitchEntity", () => {
622+
let pitches: ProjectPitch[];
623+
624+
beforeAll(async () => {
625+
await ProjectPitch.truncate();
626+
pitches = await ProjectPitchFactory.createMany(3);
627+
});
628+
629+
it("sends all records to airtable", async () => {
630+
await testAirtableUpdates(new ProjectPitchEntity(dataApi), pitches, ({ uuid, projectCountry, states }) => ({
631+
fields: {
632+
uuid,
633+
projectCountry,
634+
projectCountryName: COUNTRIES[projectCountry],
635+
states,
636+
stateNames: states.map(state => STATES[state.split(".")[0]][state])
637+
}
638+
}));
639+
});
640+
});
641+
593642
describe("ProjectReportEntity", () => {
594643
let projectUuids: Record<number, string>;
595644
let reports: ProjectReport[];
@@ -654,7 +703,7 @@ describe("AirtableEntity", () => {
654703
});
655704

656705
it("sends all records to airtable", async () => {
657-
await testAirtableUpdates(new ProjectReportEntity(), reports, ({ uuid, projectId, status, dueAt }) => ({
706+
await testAirtableUpdates(new ProjectReportEntity(dataApi), reports, ({ uuid, projectId, status, dueAt }) => ({
658707
fields: {
659708
uuid,
660709
projectUuid: projectUuids[projectId],
@@ -684,7 +733,7 @@ describe("AirtableEntity", () => {
684733
});
685734

686735
it("sends all records to airtable", async () => {
687-
await testAirtableUpdates(new SiteEntity(), sites, ({ uuid, name, projectId, status }) => ({
736+
await testAirtableUpdates(new SiteEntity(dataApi), sites, ({ uuid, name, projectId, status }) => ({
688737
fields: {
689738
uuid,
690739
name,
@@ -720,7 +769,7 @@ describe("AirtableEntity", () => {
720769
});
721770

722771
it("sends all records to airtable", async () => {
723-
await testAirtableUpdates(new SiteReportEntity(), reports, ({ id, uuid, siteId, status, dueAt }) => ({
772+
await testAirtableUpdates(new SiteReportEntity(dataApi), reports, ({ id, uuid, siteId, status, dueAt }) => ({
724773
fields: {
725774
uuid,
726775
siteUuid: siteUuids[siteId],
@@ -796,7 +845,7 @@ describe("AirtableEntity", () => {
796845

797846
it("sends all records to airtable", async () => {
798847
await testAirtableUpdates(
799-
new TreeSpeciesEntity(),
848+
new TreeSpeciesEntity(dataApi),
800849
trees,
801850
({ uuid, name, amount, collection, speciesableType, speciesableId }) => ({
802851
fields: {
@@ -826,7 +875,7 @@ describe("AirtableEntity", () => {
826875
super.getDeletePageFindOptions(deletedSince, page);
827876
}
828877
const deletedSince = new Date();
829-
const result = new Test().getDeletePageFindOptions(deletedSince, 0);
878+
const result = new Test(dataApi).getDeletePageFindOptions(deletedSince, 0);
830879
expect(result.where[Op.or]).not.toBeNull();
831880
expect(result.where[Op.or]?.[Op.and]?.updatedAt?.[Op.gte]).toBe(deletedSince);
832881
});

0 commit comments

Comments
 (0)