diff --git a/migrations/1738106535803-foodProduct.ts b/migrations/1738106535803-foodProduct.ts new file mode 100644 index 0000000..4fcae1e --- /dev/null +++ b/migrations/1738106535803-foodProduct.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FoodProduct1738106535803 implements MigrationInterface { + name = 'FoodProduct1738106535803' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "food_products" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "footprint" double precision, CONSTRAINT "PK_3aca8796e89325904061ed18b12" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "food_products_ingredients" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "quantity" double precision NOT NULL, "unit" character varying NOT NULL, "foodProductId" integer NOT NULL, "carbonEmissionFactorId" integer, CONSTRAINT "PK_fbf9b2563664e5f4dbb4f2dbac6" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "food_products_ingredients" ADD CONSTRAINT "FK_34cb9f791c7b07183b47521431e" FOREIGN KEY ("foodProductId") REFERENCES "food_products"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "food_products_ingredients" ADD CONSTRAINT "FK_311390501389e8467a7cd001ff3" FOREIGN KEY ("carbonEmissionFactorId") REFERENCES "carbon_emission_factors"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "food_products_ingredients" DROP CONSTRAINT "FK_311390501389e8467a7cd001ff3"`); + await queryRunner.query(`ALTER TABLE "food_products_ingredients" DROP CONSTRAINT "FK_34cb9f791c7b07183b47521431e"`); + await queryRunner.query(`DROP TABLE "food_products_ingredients"`); + await queryRunner.query(`DROP TABLE "food_products"`); + } +} diff --git a/package.json b/package.json index 1eb6cda..dd8226e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { - "init-project": "yarn build && yarn start-docker && yarn migration:run && yarn seed", + "init-project": "yarn build && yarn start-docker && sleep 10 && yarn migration:run && yarn seed", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", @@ -22,7 +22,7 @@ "test:e2e": "NODE_ENV=test jest --config ./test-e2e/jest-e2e.json", "typeorm": "ts-node ./node_modules/typeorm/cli -d ./config/dataSource.ts", "migration:run": "yarn build && yarn typeorm migration:run", - "migration:generate": "yarn build && yarn typeorm -- migration:generate ./src/migrations/$migrationName", + "migration:generate": "yarn build && yarn typeorm -- migration:generate ./migrations/$migrationName", "seed": "ts-node ./src/seed-dev-data.ts" }, "dependencies": { diff --git a/src/app.module.ts b/src/app.module.ts index 1d86e2e..b055c93 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule } from "@nestjs/typeorm"; import { typeorm } from "../config/dataSource"; import { CarbonEmissionFactorsModule } from "./carbonEmissionFactor/carbonEmissionFactors.module"; +import { FoodProductFootprintModule } from "./foodProductFootprint/foodProductFootprint.module"; @Module({ imports: [ @@ -16,6 +17,7 @@ import { CarbonEmissionFactorsModule } from "./carbonEmissionFactor/carbonEmissi configService.getOrThrow("typeorm"), }), CarbonEmissionFactorsModule, + FoodProductFootprintModule, ], }) export class AppModule {} diff --git a/src/foodProduct/dto/create-foodProduct.dto.ts b/src/foodProduct/dto/create-foodProduct.dto.ts new file mode 100644 index 0000000..49d4d83 --- /dev/null +++ b/src/foodProduct/dto/create-foodProduct.dto.ts @@ -0,0 +1,7 @@ +import {CreateFoodProductIngredientDto} from "./create-foodProductIngredient.dto"; + +export interface CreateFoodProductDto +{ + name: string; + ingredients: CreateFoodProductIngredientDto[]; +} diff --git a/src/foodProduct/dto/create-foodProductIngredient.dto.ts b/src/foodProduct/dto/create-foodProductIngredient.dto.ts new file mode 100644 index 0000000..47cce13 --- /dev/null +++ b/src/foodProduct/dto/create-foodProductIngredient.dto.ts @@ -0,0 +1,7 @@ + +export interface CreateFoodProductIngredientDto +{ + name: string; + quantity: number; + unit: string; +} diff --git a/src/foodProduct/foodProduct.entity.test.ts b/src/foodProduct/foodProduct.entity.test.ts new file mode 100644 index 0000000..e75f20c --- /dev/null +++ b/src/foodProduct/foodProduct.entity.test.ts @@ -0,0 +1,55 @@ +import {FoodProduct} from "./foodProduct.entity"; +import {getTestEmissionFactor} from "../seed-dev-data"; +import {FoodProductIngredient} from "./foodProductIngredient.entity"; + +let hamCheesePizza: FoodProduct; + +beforeEach(async () => { + hamCheesePizza = new FoodProduct({ + name: "hamCheesePizza", + ingredients: [ + { name: "ham", quantity: 0.1, unit: "kg" }, + { name: "cheese", quantity: 0.15, unit: "kg" }, + { name: "tomato", quantity: 0.4, unit: "kg" }, + { name: "flour", quantity: 0.7, unit: "kg" }, + { name: "oliveOil", quantity: 0.3, unit: "kg" }, + ], + }); +}); + +describe("FoodProduct", () => { + describe("constructor", () => { + it("should create a food product with its ingredients", () => { + expect(hamCheesePizza.name).toBe("hamCheesePizza"); + expect(hamCheesePizza.ingredients).toHaveLength(5); + expect(hamCheesePizza.ingredients?.[1]?.name).toBe("cheese"); + expect(hamCheesePizza.ingredients?.[2]?.quantity).toBe(0.4); + expect(hamCheesePizza.ingredients?.[3]?.unit).toBe("kg"); + }); + }); + + describe("compute footprint", () => { + it("shouldn't be able to compute the carbon footprint as carbon emission factors are unknown", () => { + expect(hamCheesePizza.computeFootprint()).toBeNull(); + expect(hamCheesePizza.footprint).toBeNull(); + }); + + it("should compute the carbon footprint for one ingredient but not the others", () => { + // Set one ingredient test emission factor. + (hamCheesePizza.ingredients as FoodProductIngredient[])[0].carbonEmissionFactor = getTestEmissionFactor("ham"); + // Compute its footprint. + expect((hamCheesePizza.ingredients as FoodProductIngredient[])[0].computeFootprint()).toBeCloseTo(0.011, 3); + expect(hamCheesePizza.computeFootprint()).toBeNull(); + }); + + it("should compute the product carbon footprint", () => { + // Set ingredients test emission factors. + for (const ingredient of (hamCheesePizza.ingredients as FoodProductIngredient[])) + ingredient.carbonEmissionFactor = getTestEmissionFactor(ingredient.name ?? ""); + + // Compute product footprint. + expect(hamCheesePizza.computeFootprint()).toBeCloseTo(0.224, 3); + expect(hamCheesePizza.footprint).toBeCloseTo(0.224, 3); + }); + }); +}); diff --git a/src/foodProduct/foodProduct.entity.ts b/src/foodProduct/foodProduct.entity.ts new file mode 100644 index 0000000..8a0d813 --- /dev/null +++ b/src/foodProduct/foodProduct.entity.ts @@ -0,0 +1,68 @@ +import {BaseEntity, Column, Entity, OneToMany, PrimaryGeneratedColumn} from "typeorm"; +import {FoodProductIngredient} from "./foodProductIngredient.entity"; + +/** + * Food product entity. + */ +@Entity("food_products") +export class FoodProduct extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ + nullable: false, + }) + name?: string; + + /** + * List of ingredients of the food product. + */ + @OneToMany(() => FoodProductIngredient, (ingredient) => ingredient.foodProduct, { + cascade: true, + }) + ingredients?: FoodProductIngredient[]; + + /** + * Computed carbon footprint of the product. + * Set to null if the carbon footprint couldn't be computed (e.g. because an ingredient is unknown). + */ + @Column({ + type: "float", + nullable: true, + }) + footprint: number|null = null; + + + constructor(props?: { + name: string, + ingredients: { + name: string, + quantity: number, + unit: string, + }[], + }) + { + super(); + + this.name = props?.name; + this.ingredients = props?.ingredients?.map((ingredientProps) => new FoodProductIngredient(ingredientProps)); + } + + + /** + * Try to compute (and set) the carbon footprint of the food product from its ingredients. + * If the carbon footprint cannot be computed, it's set to NULL. + * @return The computed carbon footprint. + */ + computeFootprint(): number|null + { + let totalFootprint: number = 0; + if (this.ingredients) for (const ingredient of this.ingredients) { + // Compute each ingredient footprint and add it to the total footprint. + const ingredientFootprint = ingredient.computeFootprint(); + if (ingredientFootprint === null) return this.footprint = null; + totalFootprint += ingredientFootprint; + } + return this.footprint = totalFootprint; + } +} diff --git a/src/foodProduct/foodProduct.service.test.ts b/src/foodProduct/foodProduct.service.test.ts new file mode 100644 index 0000000..a75e168 --- /dev/null +++ b/src/foodProduct/foodProduct.service.test.ts @@ -0,0 +1,79 @@ +import {dataSource, GreenlyDataSource} from "../../config/dataSource"; +import {FoodProductService} from "./foodProduct.service"; +import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity"; +import {seedTestCarbonEmissionFactors} from "../seed-dev-data"; +import {FoodProductIngredient} from "./foodProductIngredient.entity"; +import {FoodProduct} from "./foodProduct.entity"; + +let foodProductService: FoodProductService; + +beforeAll(async () => { + await dataSource.initialize(); + foodProductService = new FoodProductService( + dataSource.getRepository(FoodProduct), + dataSource.getRepository(CarbonEmissionFactor), + ); +}); + +describe("FoodProduct.service", () => { + it("should initialize a food product from DTO", async () => { + const hamCheesePizza = await foodProductService.initialize({ + name: "hamCheesePizza", + ingredients: [ + { name: "ham", quantity: 0.1, unit: "kg" }, + { name: "cheese", quantity: 0.15, unit: "kg" }, + { name: "tomato", quantity: 0.4, unit: "kg" }, + { name: "flour", quantity: 0.7, unit: "kg" }, + { name: "oliveOil", quantity: 0.3, unit: "kg" }, + ], + }); + + expect(hamCheesePizza.ingredients).toHaveLength(5); + expect(hamCheesePizza.ingredients?.[0].carbonEmissionFactor?.name).toBe("ham"); + expect(hamCheesePizza.ingredients?.[0].carbonEmissionFactor?.unit).toBe("kg"); + for (const ingredient of (hamCheesePizza.ingredients as FoodProductIngredient[])) + expect(ingredient.carbonEmissionFactor?.id).not.toBeUndefined(); + }); + + it("should create a food product from DTO", async () => { + const noFoodProducts = await foodProductService.findAll(); + expect(noFoodProducts).toHaveLength(0); + + const hamCheesePizza = await foodProductService.create({ + name: "hamCheesePizza", + ingredients: [ + { name: "ham", quantity: 0.1, unit: "kg" }, + { name: "cheese", quantity: 0.15, unit: "kg" }, + { name: "tomato", quantity: 0.4, unit: "kg" }, + { name: "flour", quantity: 0.7, unit: "kg" }, + { name: "oliveOil", quantity: 0.3, unit: "kg" }, + ], + }); + + const retrievedHamCheesePizza = await dataSource + .getRepository(FoodProduct) + .findOne({ + where: { name: "hamCheesePizza" }, + relations: { ingredients: { carbonEmissionFactor: true } } + }); + + expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza"); + expect(retrievedHamCheesePizza?.ingredients).toHaveLength(5); + expect(retrievedHamCheesePizza?.ingredients?.[0].carbonEmissionFactor?.name).toBe("ham"); + expect(retrievedHamCheesePizza?.ingredients?.[0].carbonEmissionFactor?.unit).toBe("kg"); + for (const ingredient of (retrievedHamCheesePizza?.ingredients as FoodProductIngredient[])) + expect(ingredient.carbonEmissionFactor?.id).not.toBeUndefined(); + + const allFoodProducts = await foodProductService.findAll(); + expect(allFoodProducts).toHaveLength(1); + }); +}); + +beforeEach(async () => { + await GreenlyDataSource.cleanDatabase(); + await seedTestCarbonEmissionFactors(); +}); + +afterAll(async () => { + await dataSource.destroy(); +}); diff --git a/src/foodProduct/foodProduct.service.ts b/src/foodProduct/foodProduct.service.ts new file mode 100644 index 0000000..fa255b0 --- /dev/null +++ b/src/foodProduct/foodProduct.service.ts @@ -0,0 +1,56 @@ +import {InjectRepository} from "@nestjs/typeorm"; +import {Repository} from "typeorm"; +import {FoodProduct} from "./foodProduct.entity"; +import {CreateFoodProductDto} from "./dto/create-foodProduct.dto"; +import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity"; + +export class FoodProductService +{ + constructor( + @InjectRepository(FoodProduct) private foodProductRepository: Repository, + @InjectRepository(CarbonEmissionFactor) private carbonEmissionFactorRepository: Repository, + ) {} + + /** + * Find all created food products, with their foodprint. + */ + findAll(): Promise + { + return this.foodProductRepository.find(); + } + + /** + * Initialize a new food product object from a provided food product creation object. + * @param rawFoodProduct Food product creation object. + */ + async initialize(rawFoodProduct: CreateFoodProductDto): Promise + { + // Try to find all matching ingredients emission factors. + const ingredientsEmissionFactors = await this.carbonEmissionFactorRepository.find({ + where: rawFoodProduct.ingredients.map((ingredient) => ( + { name: ingredient.name, unit: ingredient.unit } + )), + }); + + // Convert a simple map to retrieve ingredients emission factors from their "name + unit" key. + const ingredientsEmissionFactorsMap = Object.fromEntries( + ingredientsEmissionFactors.map((ingredient) => [ingredient.name + ";" + ingredient.unit, ingredient]) + ); + + // Create the food product object with its ingredients. + const foodProduct = new FoodProduct(rawFoodProduct); + foodProduct.ingredients?.forEach((ingredient) => { + ingredient.carbonEmissionFactor = ingredientsEmissionFactorsMap[ingredient.name + ";" + ingredient.unit] ?? null; + }); + return foodProduct; + } + + /** + * Create a new food product object from a provided food product creation object. + * @param rawFoodProduct Food product creation object. + */ + async create(rawFoodProduct: CreateFoodProductDto): Promise + { // Initialize and save the food product. + return (await this.initialize(rawFoodProduct)).save(); + } +} diff --git a/src/foodProduct/foodProductIngredient.entity.test.ts b/src/foodProduct/foodProductIngredient.entity.test.ts new file mode 100644 index 0000000..dd231d1 --- /dev/null +++ b/src/foodProduct/foodProductIngredient.entity.test.ts @@ -0,0 +1,17 @@ +import {FoodProductIngredient} from "./foodProductIngredient.entity"; +import {getTestEmissionFactor} from "../seed-dev-data"; + +let testIngredient: FoodProductIngredient; + +beforeEach(async () => { + testIngredient = new FoodProductIngredient(); + testIngredient.carbonEmissionFactor = getTestEmissionFactor("ham"); +}); + +describe("FoodProductIngredient", () => { + it("should compute a 0 footprint with undefined quantity", () => { + expect(testIngredient.quantity).toBeUndefined(); + expect(testIngredient.carbonEmissionFactor).not.toBeNull(); + expect(testIngredient.computeFootprint()).toBe(0); + }); +}); diff --git a/src/foodProduct/foodProductIngredient.entity.ts b/src/foodProduct/foodProductIngredient.entity.ts new file mode 100644 index 0000000..eb3bc30 --- /dev/null +++ b/src/foodProduct/foodProductIngredient.entity.ts @@ -0,0 +1,70 @@ +import {BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn} from "typeorm"; +import {FoodProduct} from "./foodProduct.entity"; +import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity"; + +/** + * Food product entity. + */ +@Entity("food_products_ingredients") +export class FoodProductIngredient extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + /** + * The food product of the ingredient. + */ + @ManyToOne(() => FoodProduct, (product) => product.ingredients, { + nullable: false, + }) + foodProduct: FoodProduct; + + /** + * The carbon emission factor of the ingredient. + */ + @ManyToOne(() => CarbonEmissionFactor, { + nullable: true, + }) + carbonEmissionFactor: CarbonEmissionFactor|null = null; + + @Column({ + nullable: false, + }) + name?: string; + + @Column({ + type: "float", + nullable: false, + }) + quantity?: number; + + @Column({ + nullable: false, + }) + unit?: string; + + constructor(props?: { + name: string, + quantity: number, + unit: string, + }) + { + super(); + + this.name = props?.name; + this.quantity = props?.quantity; + this.unit = props?.unit; + } + + /** + * Try to compute the carbon footprint of the food product ingredient. + * If the carbon footprint cannot be computed, it's set to NULL. + * @return The computed carbon footprint. + */ + computeFootprint(): number|null + { + if (this.carbonEmissionFactor) + return (this.quantity ?? 0) * this.carbonEmissionFactor.emissionCO2eInKgPerUnit; + else + return null; + } +} diff --git a/src/foodProductFootprint/foodProductFootprint.controller.ts b/src/foodProductFootprint/foodProductFootprint.controller.ts new file mode 100644 index 0000000..f05dac8 --- /dev/null +++ b/src/foodProductFootprint/foodProductFootprint.controller.ts @@ -0,0 +1,34 @@ +import {Body, Controller, Get, Logger, Post} from "@nestjs/common"; +import {FoodProductFootprintService} from "./foodProductFootprint.service"; +import {FoodProductService} from "../foodProduct/foodProduct.service"; +import {FoodProduct} from "../foodProduct/foodProduct.entity"; +import {CreateFoodProductDto} from "../foodProduct/dto/create-foodProduct.dto"; + +@Controller("food-product-footprint") +export class FoodProductFootprintController { + constructor( + private readonly foodProductService: FoodProductService, + private readonly foodProductFootprintService: FoodProductFootprintService, + ) {} + + @Get() + getFoodProductFootprint(): Promise { + Logger.log( + `[food-product-footprint] [GET] FoodProduct: getting all FoodProducts` + ); + return this.foodProductService.findAll(); + } + + @Post() + async createFoodProductFoodprint( + @Body() foodProductDto: CreateFoodProductDto + ): Promise { + ``; + Logger.log( + `[food-product-footprint] [POST] FoodProduct: ${foodProductDto} created` + ); + return this.foodProductFootprintService.computeAndSave( + await this.foodProductService.initialize(foodProductDto) + ); + } +} diff --git a/src/foodProductFootprint/foodProductFootprint.module.ts b/src/foodProductFootprint/foodProductFootprint.module.ts new file mode 100644 index 0000000..d098838 --- /dev/null +++ b/src/foodProductFootprint/foodProductFootprint.module.ts @@ -0,0 +1,15 @@ +import {Module} from "@nestjs/common"; +import {TypeOrmModule} from "@nestjs/typeorm"; +import {FoodProduct} from "../foodProduct/foodProduct.entity"; +import {FoodProductIngredient} from "../foodProduct/foodProductIngredient.entity"; +import {FoodProductFootprintService} from "./foodProductFootprint.service"; +import {FoodProductService} from "../foodProduct/foodProduct.service"; +import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity"; +import {FoodProductFootprintController} from "./foodProductFootprint.controller"; + +@Module({ + imports: [TypeOrmModule.forFeature([FoodProduct, FoodProductIngredient, CarbonEmissionFactor])], + providers: [FoodProductService, FoodProductFootprintService], + controllers: [FoodProductFootprintController], +}) +export class FoodProductFootprintModule {} diff --git a/src/foodProductFootprint/foodProductFootprint.service.test.ts b/src/foodProductFootprint/foodProductFootprint.service.test.ts new file mode 100644 index 0000000..0077b57 --- /dev/null +++ b/src/foodProductFootprint/foodProductFootprint.service.test.ts @@ -0,0 +1,77 @@ +import {GreenlyDataSource, dataSource} from "../../config/dataSource"; +import {seedTestCarbonEmissionFactors} from "../seed-dev-data"; +import {FoodProductService} from "../foodProduct/foodProduct.service"; +import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity"; +import {FoodProductFootprintService} from "./foodProductFootprint.service"; +import {FoodProduct} from "../foodProduct/foodProduct.entity"; + +let foodProductService: FoodProductService; +let foodProductFootprintService: FoodProductFootprintService; + +beforeAll(async () => { + await dataSource.initialize(); + foodProductService = new FoodProductService( + dataSource.getRepository(FoodProduct), + dataSource.getRepository(CarbonEmissionFactor), + ); + foodProductFootprintService = new FoodProductFootprintService(); +}); + +beforeEach(async () => { + await GreenlyDataSource.cleanDatabase(); + await seedTestCarbonEmissionFactors(); +}); + +describe("FoodProductFootprint.service", () => { + it("should compute and save carbon footprint", async () => { + let hamCheesePizza = await foodProductService.initialize({ + name: "hamCheesePizza", + ingredients: [ + { name: "ham", quantity: 0.1, unit: "kg" }, + { name: "cheese", quantity: 0.15, unit: "kg" }, + { name: "tomato", quantity: 0.4, unit: "kg" }, + { name: "flour", quantity: 0.7, unit: "kg" }, + { name: "oliveOil", quantity: 0.3, unit: "kg" }, + ], + }); + + await foodProductFootprintService.computeAndSave(hamCheesePizza); + + expect(hamCheesePizza.footprint).toBeCloseTo(0.224, 3); + + const retrievedHamCheesePizza = await dataSource + .getRepository(FoodProduct) + .findOne({ where: { name: "hamCheesePizza" } }); + + expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza"); + expect(retrievedHamCheesePizza?.footprint).toBeCloseTo(0.224, 3); + }); + + it("should fail to compute and save NULL carbon footprint", async () => { + let hamCheesePizza = await foodProductService.initialize({ + name: "hamCheesePizza", + ingredients: [ + { name: "ham", quantity: 0.1, unit: "kg" }, + { name: "cheese", quantity: 0.15, unit: "kg" }, + { name: "tomato", quantity: 0.4, unit: "kg" }, + { name: "flour", quantity: 0.7, unit: "kg" }, + { name: "anyOil", quantity: 0.3, unit: "kg" }, + ], + }); + + await foodProductFootprintService.computeAndSave(hamCheesePizza); + + expect(hamCheesePizza.footprint).toBeNull(); + + const retrievedHamCheesePizza = await dataSource + .getRepository(FoodProduct) + .findOne({ where: { name: "hamCheesePizza" } }); + + expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza"); + expect(retrievedHamCheesePizza?.footprint).toBeNull(); + }); +}); + +afterAll(async () => { + await dataSource.destroy(); +}); diff --git a/src/foodProductFootprint/foodProductFootprint.service.ts b/src/foodProductFootprint/foodProductFootprint.service.ts new file mode 100644 index 0000000..dd94faa --- /dev/null +++ b/src/foodProductFootprint/foodProductFootprint.service.ts @@ -0,0 +1,18 @@ +import {FoodProduct} from "../foodProduct/foodProduct.entity"; +import {Injectable} from "@nestjs/common"; + +@Injectable() +export class FoodProductFootprintService +{ + constructor() {} + + /** + * Compute the carbon footprint of a food product from its ingredients and save it. + * @param foodProduct The food product for which to compute the carbon footprint. + */ + async computeAndSave(foodProduct: FoodProduct): Promise + { + foodProduct.computeFootprint(); + return foodProduct.save(); + } +} diff --git a/test-e2e/foodProductFootprint.e2e-test.ts b/test-e2e/foodProductFootprint.e2e-test.ts new file mode 100644 index 0000000..5a2c467 --- /dev/null +++ b/test-e2e/foodProductFootprint.e2e-test.ts @@ -0,0 +1,80 @@ +import {INestApplication} from "@nestjs/common"; +import {Test, TestingModule} from "@nestjs/testing"; +import * as request from "supertest"; +import {dataSource, GreenlyDataSource} from "../config/dataSource"; +import {AppModule} from "../src/app.module"; +import {CarbonEmissionFactor} from "../src/carbonEmissionFactor/carbonEmissionFactor.entity"; +import {seedTestCarbonEmissionFactors} from "../src/seed-dev-data"; +import {FoodProductService} from "../src/foodProduct/foodProduct.service"; +import {FoodProduct} from "../src/foodProduct/foodProduct.entity"; +import {FoodProductFootprintService} from "../src/foodProductFootprint/foodProductFootprint.service"; + +beforeAll(async () => { + await dataSource.initialize(); + await GreenlyDataSource.cleanDatabase(); +}); + +afterAll(async () => { + await dataSource.destroy(); +}); + +describe("FoodProductFootprintController", () => { + let app: INestApplication; + let defaultProducts: FoodProduct[]; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + await seedTestCarbonEmissionFactors(); + + const foodProductService = new FoodProductService(dataSource.getRepository(FoodProduct), dataSource.getRepository(CarbonEmissionFactor)); + const foodProductFootprintService = new FoodProductFootprintService(); + await foodProductFootprintService.computeAndSave(await foodProductService.initialize({ + name: "ketchup", + ingredients: [ + { name: "tomato", quantity: 0.9, unit: "kg" }, + { name: "vinegar", quantity: 0.1, unit: "kg" }, + ], + })); + + defaultProducts = await dataSource + .getRepository(FoodProduct) + .find(); + }); + + it("GET /food-product-footprint", async () => { + return request(app.getHttpServer()) + .get("/food-product-footprint") + .expect(200) + .expect(({ body }) => { + expect(body).toEqual(defaultProducts); + }); + }); + + it("POST /food-product-footprint", async () => { + const hamCheesePizza = { + name: "hamCheesePizza", + ingredients: [ + { name: "ham", quantity: 0.1, unit: "kg" }, + { name: "cheese", quantity: 0.15, unit: "kg" }, + { name: "tomato", quantity: 0.4, unit: "kg" }, + { name: "flour", quantity: 0.7, unit: "kg" }, + { name: "oliveOil", quantity: 0.3, unit: "kg" }, + ], + }; + return request(app.getHttpServer()) + .post("/food-product-footprint") + .send(hamCheesePizza) + .expect(201) + .expect(({ body }) => { + expect(body).toMatchObject(hamCheesePizza); + expect(body.footprint).not.toBeNull(); + expect(body.footprint).toBeCloseTo(0.224, 3); + }); + }); +});