Skip to content

Commit

Permalink
[TM-1402] Include taxonId in the previous planting count payload.
Browse files Browse the repository at this point in the history
  • Loading branch information
roguenet committed Dec 16, 2024
1 parent 531c13e commit 88b9de9
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 152 deletions.
16 changes: 13 additions & 3 deletions apps/entity-service/src/trees/dto/establishment-trees.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { JsonApiAttributes } from "@terramatch-microservices/common/dto/json-api
import { JsonApiDto } from "@terramatch-microservices/common/decorators";
import { ApiProperty } from "@nestjs/swagger";

export class PreviousPlantingCountDto {
@ApiProperty({ nullable: true, description: "Taxonomic ID for this tree species row" })
taxonId?: string;

@ApiProperty({
description: "Number of trees of this type that have been planted in all previous reports on this entity."
})
amount: number;
}

// The ID for this DTO is formed of "entityType|entityUuid". This is a virtual resource, not directly
// backed by a single DB table.
@JsonApiDto({ type: "establishmentTrees", id: "string" })
Expand All @@ -14,10 +24,10 @@ export class EstablishmentsTreesDto extends JsonApiAttributes<EstablishmentsTree

@ApiProperty({
type: "object",
additionalProperties: { type: "number" },
additionalProperties: { $ref: "#/components/schemas/PreviousPlantingCount" },
nullable: true,
description: "If the entity in this request is a report, the sum totals of previous planting by species.",
example: { "Aster persaliens": 256, "Cirsium carniolicum": 1024 }
example: { "Aster persaliens": { amount: 256 }, "Cirsium carniolicum": { taxonId: "wfo-0000130112", amount: 1024 } }
})
previousPlantingCounts?: Record<string, number>;
previousPlantingCounts?: Record<string, PreviousPlantingCountDto>;
}
254 changes: 138 additions & 116 deletions apps/entity-service/src/trees/tree.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "@terramatch-microservices/database/factories";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { DateTime } from "luxon";
import { PreviousPlantingCountDto } from "./dto/establishment-trees.dto";

describe("TreeService", () => {
let service: TreeService;
Expand All @@ -35,132 +36,153 @@ describe("TreeService", () => {
jest.restoreAllMocks();
});

it("should return an empty array with no matches", async () => {
const result = await service.searchScientificNames("test");
expect(result.length).toBe(0);
});
describe("searchScientificName", () => {
it("should return an empty array with no matches", async () => {
const result = await service.searchScientificNames("test");
expect(result.length).toBe(0);
});

it("should return the matching entries", async () => {
const tree1 = await TreeSpeciesResearchFactory.create({ scientificName: "Lorem asdfium" });
const tree2 = await TreeSpeciesResearchFactory.create({ scientificName: "Lorem qasdium" });
const tree3 = await TreeSpeciesResearchFactory.create({ scientificName: "Ipsum loremium" });
await TreeSpeciesResearchFactory.create({ scientificName: "Alorem ipsium" });
await TreeSpeciesResearchFactory.create({ scientificName: "Fakem ipslorem" });
const result = await service.searchScientificNames("lore");
expect(result.length).toBe(3);
expect(result).toContainEqual(pick(tree1, ["taxonId", "scientificName"]));
expect(result).toContainEqual(pick(tree2, ["taxonId", "scientificName"]));
expect(result).toContainEqual(pick(tree3, ["taxonId", "scientificName"]));
});
it("should return the matching entries", async () => {
const tree1 = await TreeSpeciesResearchFactory.create({ scientificName: "Lorem asdfium" });
const tree2 = await TreeSpeciesResearchFactory.create({ scientificName: "Lorem qasdium" });
const tree3 = await TreeSpeciesResearchFactory.create({ scientificName: "Ipsum loremium" });
await TreeSpeciesResearchFactory.create({ scientificName: "Alorem ipsium" });
await TreeSpeciesResearchFactory.create({ scientificName: "Fakem ipslorem" });
const result = await service.searchScientificNames("lore");
expect(result.length).toBe(3);
expect(result).toContainEqual(pick(tree1, ["taxonId", "scientificName"]));
expect(result).toContainEqual(pick(tree2, ["taxonId", "scientificName"]));
expect(result).toContainEqual(pick(tree3, ["taxonId", "scientificName"]));
});

it("should return 10 entries maximum", async () => {
for (let ii = 0; ii < 12; ii++) {
await TreeSpeciesResearchFactory.create({ scientificName: `Tree${faker.word.words()}` });
}
it("should return 10 entries maximum", async () => {
for (let ii = 0; ii < 12; ii++) {
await TreeSpeciesResearchFactory.create({ scientificName: `Tree${faker.word.words()}` });
}

const result = await service.searchScientificNames("tree");
expect(result.length).toBe(10);
const result = await service.searchScientificNames("tree");
expect(result.length).toBe(10);
});
});

it("should return establishment trees", async () => {
const project = await ProjectFactory.create();
const projectReport = await ProjectReportFactory.create({ projectId: project.id });
const site = await SiteFactory.create({ projectId: project.id });
const siteReport = await SiteReportFactory.create({ siteId: site.id });
const nursery = await NurseryFactory.create({ projectId: project.id });
const nurseryReport = await NurseryReportFactory.create({ nurseryId: nursery.id });

const projectTrees = (await TreeSpeciesFactory.forProject.createMany(3, { speciesableId: project.id }))
.map(({ name }) => name)
.sort();
const siteTrees = (await TreeSpeciesFactory.forSite.createMany(2, { speciesableId: site.id }))
.map(({ name }) => name)
.sort();
const nurseryTrees = (await TreeSpeciesFactory.forNursery.createMany(4, { speciesableId: nursery.id }))
.map(({ name }) => name)
.sort();

let result = await service.getEstablishmentTrees("project-reports", projectReport.uuid);
expect(result.sort()).toEqual(projectTrees);
result = await service.getEstablishmentTrees("sites", site.uuid);
expect(result.sort()).toEqual(projectTrees);
result = await service.getEstablishmentTrees("nurseries", nursery.uuid);
expect(result.sort()).toEqual(projectTrees);

result = await service.getEstablishmentTrees("site-reports", siteReport.uuid);
expect(result.sort()).toEqual(uniq([...siteTrees, ...projectTrees]).sort());
result = await service.getEstablishmentTrees("nursery-reports", nurseryReport.uuid);
expect(result.sort()).toEqual(uniq([...nurseryTrees, ...projectTrees]).sort());
});
describe("getEstablishmentTrees", () => {
it("should return establishment trees", async () => {
const project = await ProjectFactory.create();
const projectReport = await ProjectReportFactory.create({ projectId: project.id });
const site = await SiteFactory.create({ projectId: project.id });
const siteReport = await SiteReportFactory.create({ siteId: site.id });
const nursery = await NurseryFactory.create({ projectId: project.id });
const nurseryReport = await NurseryReportFactory.create({ nurseryId: nursery.id });

const projectTrees = (await TreeSpeciesFactory.forProject.createMany(3, { speciesableId: project.id }))
.map(({ name }) => name)
.sort();
const siteTrees = (await TreeSpeciesFactory.forSite.createMany(2, { speciesableId: site.id }))
.map(({ name }) => name)
.sort();
const nurseryTrees = (await TreeSpeciesFactory.forNursery.createMany(4, { speciesableId: nursery.id }))
.map(({ name }) => name)
.sort();

let result = await service.getEstablishmentTrees("project-reports", projectReport.uuid);
expect(result.sort()).toEqual(projectTrees);
result = await service.getEstablishmentTrees("sites", site.uuid);
expect(result.sort()).toEqual(projectTrees);
result = await service.getEstablishmentTrees("nurseries", nursery.uuid);
expect(result.sort()).toEqual(projectTrees);

result = await service.getEstablishmentTrees("site-reports", siteReport.uuid);
expect(result.sort()).toEqual(uniq([...siteTrees, ...projectTrees]).sort());
result = await service.getEstablishmentTrees("nursery-reports", nurseryReport.uuid);
expect(result.sort()).toEqual(uniq([...nurseryTrees, ...projectTrees]).sort());
});

it("throws with bad inputs to establishment trees", async () => {
await expect(service.getEstablishmentTrees("sites", "fakeuuid")).rejects.toThrow(NotFoundException);
await expect(service.getEstablishmentTrees("site-reports", "fakeuuid")).rejects.toThrow(NotFoundException);
// @ts-expect-error intentionally sneaking in a bad entity type
await expect(service.getEstablishmentTrees("nothing-burgers", "fakeuuid")).rejects.toThrow(BadRequestException);
it("throws with bad inputs to establishment trees", async () => {
await expect(service.getEstablishmentTrees("sites", "fakeuuid")).rejects.toThrow(NotFoundException);
await expect(service.getEstablishmentTrees("site-reports", "fakeuuid")).rejects.toThrow(NotFoundException);
// @ts-expect-error intentionally sneaking in a bad entity type
await expect(service.getEstablishmentTrees("nothing-burgers", "fakeuuid")).rejects.toThrow(BadRequestException);
});
});

it("returns previous planting data", async () => {
const project = await ProjectFactory.create();
const projectReport1 = await ProjectReportFactory.create({ projectId: project.id });
const projectReport2 = await ProjectReportFactory.create({
projectId: project.id,
dueAt: DateTime.fromJSDate(projectReport1.dueAt).plus({ months: 3 }).toJSDate()
});
const site = await SiteFactory.create({ projectId: project.id });
const siteReport1 = await SiteReportFactory.create({ siteId: site.id });
const siteReport2 = await SiteReportFactory.create({
siteId: site.id,
dueAt: DateTime.fromJSDate(siteReport1.dueAt).plus({ months: 3 }).toJSDate()
});
const siteReport3 = await SiteReportFactory.create({
siteId: site.id,
dueAt: DateTime.fromJSDate(siteReport2.dueAt).plus({ months: 3 }).toJSDate()
});
const nursery = await NurseryFactory.create({ projectId: project.id });
const nurseryReport1 = await NurseryReportFactory.create({ nurseryId: nursery.id });
const nurseryReport2 = await NurseryReportFactory.create({
nurseryId: nursery.id,
dueAt: DateTime.fromJSDate(nurseryReport1.dueAt).plus({ months: 3 }).toJSDate()
describe("getPreviousPlanting", () => {
it("returns previous planting data", async () => {
const project = await ProjectFactory.create();
const projectReport1 = await ProjectReportFactory.create({ projectId: project.id });
const projectReport2 = await ProjectReportFactory.create({
projectId: project.id,
dueAt: DateTime.fromJSDate(projectReport1.dueAt).plus({ months: 3 }).toJSDate()
});
const site = await SiteFactory.create({ projectId: project.id });
const siteReport1 = await SiteReportFactory.create({ siteId: site.id });
const siteReport2 = await SiteReportFactory.create({
siteId: site.id,
dueAt: DateTime.fromJSDate(siteReport1.dueAt).plus({ months: 3 }).toJSDate()
});
const siteReport3 = await SiteReportFactory.create({
siteId: site.id,
dueAt: DateTime.fromJSDate(siteReport2.dueAt).plus({ months: 3 }).toJSDate()
});
const nursery = await NurseryFactory.create({ projectId: project.id });
const nurseryReport1 = await NurseryReportFactory.create({ nurseryId: nursery.id });
const nurseryReport2 = await NurseryReportFactory.create({
nurseryId: nursery.id,
dueAt: DateTime.fromJSDate(nurseryReport1.dueAt).plus({ months: 3 }).toJSDate()
});

const reduceTreeCounts = (counts: Record<string, PreviousPlantingCountDto>, tree: TreeSpecies) => ({
...counts,
[tree.name]: {
taxonId: counts[tree.name]?.taxonId ?? tree.taxonId,
amount: (counts[tree.name]?.amount ?? 0) + (tree.amount ?? 0)
}
});
const projectReportTrees = await TreeSpeciesFactory.forProjectReport.createMany(3, {
speciesableId: projectReport1.id
});
projectReportTrees.push(
await TreeSpeciesFactory.forProjectReport.create({
speciesableId: projectReport1.id,
taxonId: "wfo-projectreporttree"
})
);
let result = await service.getPreviousPlanting("project-reports", projectReport2.uuid);
expect(result).toMatchObject(projectReportTrees.reduce(reduceTreeCounts, {}));

const siteReport1Trees = await TreeSpeciesFactory.forSiteReport.createMany(3, { speciesableId: siteReport1.id });
siteReport1Trees.push(
await TreeSpeciesFactory.forSiteReport.create({
speciesableId: siteReport1.id,
taxonId: "wfo-sitereporttree"
})
);
const siteReport2Trees = await TreeSpeciesFactory.forSiteReport.createMany(3, { speciesableId: siteReport2.id });
result = await service.getPreviousPlanting("site-reports", siteReport1.uuid);
expect(result).toMatchObject({});
result = await service.getPreviousPlanting("site-reports", siteReport2.uuid);
const siteReport1TreesReduced = siteReport1Trees.reduce(reduceTreeCounts, {});
expect(result).toMatchObject(siteReport1TreesReduced);
result = await service.getPreviousPlanting("site-reports", siteReport3.uuid);
expect(result).toMatchObject(siteReport2Trees.reduce(reduceTreeCounts, siteReport1TreesReduced));

result = await service.getPreviousPlanting("nursery-reports", nurseryReport2.uuid);
expect(result).toMatchObject({});
const nurseryReportTrees = await TreeSpeciesFactory.forNurseryReport.createMany(5, {
speciesableId: nurseryReport1.id
});
result = await service.getPreviousPlanting("nursery-reports", nurseryReport2.uuid);
expect(result).toMatchObject(nurseryReportTrees.reduce(reduceTreeCounts, {}));
});

const reduceTreeCounts = (counts: Record<string, number>, tree: TreeSpecies) => ({
...counts,
[tree.name]: (counts[tree.name] ?? 0) + (tree.amount ?? 0)
});
const projectReportTrees = await TreeSpeciesFactory.forProjectReport.createMany(3, {
speciesableId: projectReport1.id
it("handles bad input to get previous planting with undefined or an exception", async () => {
expect(await service.getPreviousPlanting("sites", "fakeuuid")).toBeUndefined();
await expect(service.getPreviousPlanting("project-reports", "fakeuuid")).rejects.toThrow(NotFoundException);
await expect(service.getPreviousPlanting("site-reports", "fakeuuid")).rejects.toThrow(NotFoundException);
await expect(service.getPreviousPlanting("nursery-reports", "fakeuuid")).rejects.toThrow(NotFoundException);
// @ts-expect-error intentionally bad report type
await expect(service.getPreviousPlanting("nothing-burger-reports", "fakeuuid")).rejects.toThrow(
BadRequestException
);
});
let result = await service.getPreviousPlanting("project-reports", projectReport2.uuid);
expect(result).toMatchObject(projectReportTrees.reduce(reduceTreeCounts, {}));

const siteReport1Trees = await TreeSpeciesFactory.forSiteReport.createMany(3, { speciesableId: siteReport1.id });
const siteReport2Trees = await TreeSpeciesFactory.forSiteReport.createMany(3, { speciesableId: siteReport2.id });
result = await service.getPreviousPlanting("site-reports", siteReport1.uuid);
expect(result).toMatchObject({});
result = await service.getPreviousPlanting("site-reports", siteReport2.uuid);
const siteReport1TreesReduced = siteReport1Trees.reduce(reduceTreeCounts, {});
expect(result).toMatchObject(siteReport1TreesReduced);
result = await service.getPreviousPlanting("site-reports", siteReport3.uuid);
expect(result).toMatchObject(siteReport2Trees.reduce(reduceTreeCounts, siteReport1TreesReduced));

result = await service.getPreviousPlanting("nursery-reports", nurseryReport2.uuid);
expect(result).toMatchObject({});
const nurseryReportTrees = await TreeSpeciesFactory.forNurseryReport.createMany(5, {
speciesableId: nurseryReport1.id
});
result = await service.getPreviousPlanting("nursery-reports", nurseryReport2.uuid);
expect(result).toMatchObject(nurseryReportTrees.reduce(reduceTreeCounts, {}));
});

it("handles bad input to get previous planting with undefined or an exception", async () => {
expect(await service.getPreviousPlanting("sites", "fakeuuid")).toBeUndefined();
await expect(service.getPreviousPlanting("project-reports", "fakeuuid")).rejects.toThrow(NotFoundException);
await expect(service.getPreviousPlanting("site-reports", "fakeuuid")).rejects.toThrow(NotFoundException);
await expect(service.getPreviousPlanting("nursery-reports", "fakeuuid")).rejects.toThrow(NotFoundException);
// @ts-expect-error intentionally bad report type
await expect(service.getPreviousPlanting("nothing-burger-reports", "fakeuuid")).rejects.toThrow(
BadRequestException
);
});
});
15 changes: 11 additions & 4 deletions apps/entity-service/src/trees/tree.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { Op } from "sequelize";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { filter, flatten, uniq } from "lodash";
import { PreviousPlantingCountDto } from "./dto/establishment-trees.dto";

export const ESTABLISHMENT_REPORTS = ["project-reports", "site-reports", "nursery-reports"] as const;
export type EstablishmentReport = (typeof ESTABLISHMENT_REPORTS)[number];
Expand Down Expand Up @@ -103,7 +104,10 @@ export class TreeService {
}
}

async getPreviousPlanting(entity: EstablishmentEntity, uuid: string): Promise<Record<string, number>> {
async getPreviousPlanting(
entity: EstablishmentEntity,
uuid: string
): Promise<Record<string, PreviousPlantingCountDto>> {
if (!isReport(entity)) return undefined;

const treeReportWhere = (parentAttribute: string, report: ProjectReport | SiteReport | NurseryReport) => ({
Expand All @@ -115,7 +119,7 @@ export class TreeService {
include: [
{
association: "treeSpecies",
attributes: ["name", "amount"],
attributes: ["taxonId", "name", "amount"],
where: { amount: { [Op.gt]: 0 } }
}
]
Expand Down Expand Up @@ -161,10 +165,13 @@ export class TreeService {
}

const trees = flatten(records.map(({ treeSpecies }) => treeSpecies));
return trees.reduce<Record<string, number>>(
return trees.reduce<Record<string, PreviousPlantingCountDto>>(
(counts, tree) => ({
...counts,
[tree.name]: (counts[tree.name] ?? 0) + (tree.amount ?? 0)
[tree.name]: {
taxonId: counts[tree.name]?.taxonId ?? tree.taxonId,
amount: (counts[tree.name]?.amount ?? 0) + (tree.amount ?? 0)
}
}),
{}
);
Expand Down
Loading

0 comments on commit 88b9de9

Please sign in to comment.