Add food products and their ingredients, with their carbon footprint calculator.
This commit is contained in:
parent
b29356bb8b
commit
a038de4294
16 changed files with 606 additions and 2 deletions
19
migrations/1738106535803-foodProduct.ts
Normal file
19
migrations/1738106535803-foodProduct.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class FoodProduct1738106535803 implements MigrationInterface {
|
||||
name = 'FoodProduct1738106535803'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -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 {}
|
||||
|
|
7
src/foodProduct/dto/create-foodProduct.dto.ts
Normal file
7
src/foodProduct/dto/create-foodProduct.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {CreateFoodProductIngredientDto} from "./create-foodProductIngredient.dto";
|
||||
|
||||
export interface CreateFoodProductDto
|
||||
{
|
||||
name: string;
|
||||
ingredients: CreateFoodProductIngredientDto[];
|
||||
}
|
7
src/foodProduct/dto/create-foodProductIngredient.dto.ts
Normal file
7
src/foodProduct/dto/create-foodProductIngredient.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
|
||||
export interface CreateFoodProductIngredientDto
|
||||
{
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
}
|
55
src/foodProduct/foodProduct.entity.test.ts
Normal file
55
src/foodProduct/foodProduct.entity.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
68
src/foodProduct/foodProduct.entity.ts
Normal file
68
src/foodProduct/foodProduct.entity.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
79
src/foodProduct/foodProduct.service.test.ts
Normal file
79
src/foodProduct/foodProduct.service.test.ts
Normal file
|
@ -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();
|
||||
});
|
56
src/foodProduct/foodProduct.service.ts
Normal file
56
src/foodProduct/foodProduct.service.ts
Normal file
|
@ -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<FoodProduct>,
|
||||
@InjectRepository(CarbonEmissionFactor) private carbonEmissionFactorRepository: Repository<CarbonEmissionFactor>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find all created food products, with their foodprint.
|
||||
*/
|
||||
findAll(): Promise<FoodProduct[]>
|
||||
{
|
||||
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<FoodProduct>
|
||||
{
|
||||
// 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<FoodProduct>
|
||||
{ // Initialize and save the food product.
|
||||
return (await this.initialize(rawFoodProduct)).save();
|
||||
}
|
||||
}
|
17
src/foodProduct/foodProductIngredient.entity.test.ts
Normal file
17
src/foodProduct/foodProductIngredient.entity.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
70
src/foodProduct/foodProductIngredient.entity.ts
Normal file
70
src/foodProduct/foodProductIngredient.entity.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
34
src/foodProductFootprint/foodProductFootprint.controller.ts
Normal file
34
src/foodProductFootprint/foodProductFootprint.controller.ts
Normal file
|
@ -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<FoodProduct[]> {
|
||||
Logger.log(
|
||||
`[food-product-footprint] [GET] FoodProduct: getting all FoodProducts`
|
||||
);
|
||||
return this.foodProductService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createFoodProductFoodprint(
|
||||
@Body() foodProductDto: CreateFoodProductDto
|
||||
): Promise<FoodProduct> {
|
||||
``;
|
||||
Logger.log(
|
||||
`[food-product-footprint] [POST] FoodProduct: ${foodProductDto} created`
|
||||
);
|
||||
return this.foodProductFootprintService.computeAndSave(
|
||||
await this.foodProductService.initialize(foodProductDto)
|
||||
);
|
||||
}
|
||||
}
|
15
src/foodProductFootprint/foodProductFootprint.module.ts
Normal file
15
src/foodProductFootprint/foodProductFootprint.module.ts
Normal file
|
@ -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 {}
|
|
@ -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();
|
||||
});
|
18
src/foodProductFootprint/foodProductFootprint.service.ts
Normal file
18
src/foodProductFootprint/foodProductFootprint.service.ts
Normal file
|
@ -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>
|
||||
{
|
||||
foodProduct.computeFootprint();
|
||||
return foodProduct.save();
|
||||
}
|
||||
}
|
80
test-e2e/foodProductFootprint.e2e-test.ts
Normal file
80
test-e2e/foodProductFootprint.e2e-test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue