feat(backend): Abfuellungen und Produkte (Modul 23)

ProductsModule (GET/POST /products, GET/PATCH /products/:id) und
BottlingModule (GET/POST /bottling-batches) mit transaktionaler
Reduzierung der HoneyBatch-Restmenge beim Abfuellen; Ablehnung bei
Abfuellmenge > verfuegbarer Menge. Neue Prisma-Modelle Product und
BottlingBatch (Migration bottling_products).
This commit is contained in:
Nick Szaunig committed 2026-08-22 11:08:09 +02:00
1 parent 8b7fd61c1c
commit e82cae257b
17 files changed
+916 -2

No files matched your search

@@ -0,0 +1,59 @@
-- CreateEnum
CREATE TYPE "ProductStatus" AS ENUM ('ACTIVE', 'DISCONTINUED');
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sku" TEXT,
"packagingSize" DOUBLE PRECISION,
"weight" DOUBLE PRECISION,
"salesUnit" TEXT,
"status" "ProductStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BottlingBatch" (
"id" TEXT NOT NULL,
"honeyBatchId" TEXT NOT NULL,
"productId" TEXT,
"date" TIMESTAMP(3) NOT NULL,
"jarSize" DOUBLE PRECISION NOT NULL,
"jarCount" INTEGER NOT NULL,
"totalAmount" DOUBLE PRECISION NOT NULL,
"bestBeforeDate" TIMESTAMP(3) NOT NULL,
"lotNumber" TEXT NOT NULL,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BottlingBatch_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Product_sku_key" ON "Product"("sku");
-- CreateIndex
CREATE INDEX "Product_status_idx" ON "Product"("status");
-- CreateIndex
CREATE UNIQUE INDEX "BottlingBatch_lotNumber_key" ON "BottlingBatch"("lotNumber");
-- CreateIndex
CREATE INDEX "BottlingBatch_honeyBatchId_idx" ON "BottlingBatch"("honeyBatchId");
-- CreateIndex
CREATE INDEX "BottlingBatch_productId_idx" ON "BottlingBatch"("productId");
-- AddForeignKey
ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+49 -1
View File
@@ -49,6 +49,7 @@ model User {
createdTasks Task[] @relation("TaskCreator") createdTasks Task[] @relation("TaskCreator")
colonyMoves ColonyApiaryHistory[] colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[] auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
@@index([role]) @@index([role])
} }
@@ -528,7 +529,8 @@ model HoneyBatch {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
measurements HoneyMeasurement[] measurements HoneyMeasurement[]
bottlingBatches BottlingBatch[]
@@index([status]) @@index([status])
@@index([extractionId]) @@index([extractionId])
@@ -624,3 +626,49 @@ model StockMovement {
@@index([stockItemId, date]) @@index([stockItemId, date])
} }
// ---------------------------------------------------------------------------
// Abfüllungen und Produkte (Abschnitt 4.19, 4.20) – v1.x Modul 23
// ---------------------------------------------------------------------------
enum ProductStatus {
ACTIVE
DISCONTINUED
}
model Product {
id String @id @default(cuid())
name String
description String?
sku String? @unique
packagingSize Float?
weight Float?
salesUnit String?
status ProductStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[]
@@index([status])
}
model BottlingBatch {
id String @id @default(cuid())
honeyBatchId String
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
productId String?
product Product? @relation(fields: [productId], references: [id])
date DateTime
jarSize Float
jarCount Int
totalAmount Float
bestBeforeDate DateTime
lotNumber String @unique
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([honeyBatchId])
@@index([productId])
}
+4
View File
@@ -22,6 +22,8 @@ import { DashboardModule } from './dashboard/dashboard.module';
import { SearchModule } from './search/search.module'; import { SearchModule } from './search/search.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module'; import { QrModule } from './qr/qr.module';
import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard'; import { RolesGuard } from './auth/guards/roles.guard';
@@ -49,6 +51,8 @@ import { RolesGuard } from './auth/guards/roles.guard';
SearchModule, SearchModule,
ExportModule, ExportModule,
QrModule, QrModule,
ProductsModule,
BottlingModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [
@@ -0,0 +1,34 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { BottlingService } from './bottling.service';
@ApiTags('bottling')
@ApiBearerAuth()
@Controller('bottling-batches')
export class BottlingController {
constructor(private readonly bottlingService: BottlingService) {}
@Get()
findAll(@Query('honeyBatchId') honeyBatchId?: string) {
return this.bottlingService.findAll({ honeyBatchId });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.bottlingService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateBottlingBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.bottlingService.create(dto, actor.id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service';
@Module({
controllers: [BottlingController],
providers: [BottlingService],
})
export class BottlingModule {}
@@ -0,0 +1,124 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BottlingService } from './bottling.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('BottlingService', () => {
let service: BottlingService;
let prisma: {
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
product: { findUnique: jest.Mock };
bottlingBatch: {
findUnique: jest.Mock;
findMany: jest.Mock;
create: jest.Mock;
};
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let tx: {
bottlingBatch: { create: jest.Mock };
honeyBatch: { update: jest.Mock };
};
beforeEach(() => {
tx = {
bottlingBatch: { create: jest.fn() },
honeyBatch: { update: jest.fn() },
};
prisma = {
honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
product: { findUnique: jest.fn() },
bottlingBatch: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn() };
service = new BottlingService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('throws NotFoundException when the honey batch does not exist', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce(null);
await expect(
service.create(
{
honeyBatchId: 'missing',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects when the bottled amount exceeds the available honey batch amount', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 5,
});
await expect(
service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 20,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('creates a bottling batch and reduces the honey batch amount transactionally', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 25,
});
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
totalAmount: 25,
});
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
id: 'b1',
totalAmount: 25,
});
const result = await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 50,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(result).toEqual({ id: 'b1', totalAmount: 25 });
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 0, status: 'BOTTLED' },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
action: 'CREATE',
}),
);
});
});
@@ -0,0 +1,108 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { BottlingBatch } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
const bottlingBatchInclude = {
honeyBatch: true,
product: true,
} as const;
export interface BottlingBatchFilter {
honeyBatchId?: string;
}
@Injectable()
export class BottlingService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(filter: BottlingBatchFilter = {}) {
return this.prisma.bottlingBatch.findMany({
where: { honeyBatchId: filter.honeyBatchId },
include: bottlingBatchInclude,
orderBy: { date: 'desc' },
});
}
async findById(id: string) {
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
where: { id },
include: bottlingBatchInclude,
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
return bottlingBatch;
}
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
const honeyBatch = await this.prisma.honeyBatch.findUnique({
where: { id: dto.honeyBatchId },
});
if (!honeyBatch) {
throw new NotFoundException('Honey batch not found');
}
if (dto.productId) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
const totalAmount = (dto.jarSize * dto.jarCount) / 1000;
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
throw new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
);
}
const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
async (tx) => {
const created = await tx.bottlingBatch.create({
data: {
honeyBatchId: dto.honeyBatchId,
productId: dto.productId,
date: new Date(dto.date),
jarSize: dto.jarSize,
jarCount: dto.jarCount,
totalAmount,
bestBeforeDate: new Date(dto.bestBeforeDate),
lotNumber: dto.lotNumber,
userId: actingUserId,
},
});
await tx.honeyBatch.update({
where: { id: dto.honeyBatchId },
data: {
amount:
honeyBatch.amount !== null
? honeyBatch.amount - totalAmount
: null,
status: 'BOTTLED',
},
});
return created;
},
);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: bottlingBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: bottlingBatch,
});
return this.findById(bottlingBatch.id);
}
}
@@ -0,0 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsPositive,
IsString,
} from 'class-validator';
export class CreateBottlingBatchDto {
@ApiProperty()
@IsString()
honeyBatchId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
productId?: string;
@ApiProperty()
@IsDateString()
date!: string;
@ApiProperty({ description: 'Glasgröße in Gramm' })
@IsNumber()
@IsPositive()
jarSize!: number;
@ApiProperty()
@IsInt()
@IsPositive()
jarCount!: number;
@ApiProperty()
@IsDateString()
bestBeforeDate!: string;
@ApiProperty()
@IsString()
lotNumber!: string;
}
@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateProductDto {
@ApiProperty()
@IsString()
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sku?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
packagingSize?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
weight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
salesUnit?: string;
}
@@ -0,0 +1,11 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { ProductStatus } from '../../../generated/prisma';
import { CreateProductDto } from './create-product.dto';
export class UpdateProductDto extends PartialType(CreateProductDto) {
@ApiPropertyOptional({ enum: ProductStatus })
@IsOptional()
@IsEnum(ProductStatus)
status?: ProductStatus;
}
@@ -0,0 +1,53 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ProductStatus, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { ProductsService } from './products.service';
@ApiTags('products')
@ApiBearerAuth()
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
findAll(@Query('status') status?: ProductStatus) {
return this.productsService.findAll({ status });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateProductDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.productsService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateProductDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.productsService.update(id, dto, actor.id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService],
})
export class ProductsModule {}
@@ -0,0 +1,74 @@
import { NotFoundException } from '@nestjs/common';
import { ProductsService } from './products.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('ProductsService', () => {
let service: ProductsService;
let prisma: {
product: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
prisma = {
product: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
auditLog = { record: jest.fn() };
service = new ProductsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('creates a product and records an audit log entry', async () => {
const created = { id: 'p1', name: 'Sommerhonig' };
prisma.product.create.mockResolvedValueOnce(created);
const result = await service.create({ name: 'Sommerhonig' }, 'user-1');
expect(result).toEqual(created);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Product', action: 'CREATE' }),
);
});
it('throws NotFoundException when the product does not exist', async () => {
prisma.product.findUnique.mockResolvedValueOnce(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('updates a product and records before/after audit data', async () => {
const before = { id: 'p1', name: 'Sommerhonig' };
const after = { id: 'p1', name: 'Sommerhonig 500g' };
prisma.product.findUnique.mockResolvedValueOnce(before);
prisma.product.update.mockResolvedValueOnce(after);
const result = await service.update(
'p1',
{ name: 'Sommerhonig 500g' },
'user-1',
);
expect(result).toEqual(after);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: before,
afterData: after,
}),
);
});
});
@@ -0,0 +1,66 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Product, ProductStatus } from '../../generated/prisma';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
export interface ProductFilter {
status?: ProductStatus;
}
@Injectable()
export class ProductsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(filter: ProductFilter = {}): Promise<Product[]> {
return this.prisma.product.findMany({
where: { status: filter.status },
orderBy: { name: 'asc' },
});
}
async findById(id: string): Promise<Product> {
const product = await this.prisma.product.findUnique({ where: { id } });
if (!product) {
throw new NotFoundException('Product not found');
}
return product;
}
async create(dto: CreateProductDto, actingUserId: string): Promise<Product> {
const product = await this.prisma.product.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Product',
entityId: product.id,
action: 'CREATE',
userId: actingUserId,
afterData: product,
});
return product;
}
async update(
id: string,
dto: UpdateProductDto,
actingUserId: string,
): Promise<Product> {
const before = await this.findById(id);
const product = await this.prisma.product.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record({
entityType: 'Product',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: product,
});
return product;
}
}
+137
View File
@@ -0,0 +1,137 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface HoneyBatchBody {
amount: number | null;
status: string;
}
interface BottlingBatchBody {
id: string;
totalAmount: number;
lotNumber: string;
}
describe('Bottling (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `bottling-e2e-beekeeper-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let beekeeperToken: string;
let apiaryId: string;
let colonyId: string;
let harvestId: string;
let extractionId: string;
let honeyBatchId: string;
let bottlingBatchId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
},
});
beekeeperId = beekeeper.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
const apiary = await prisma.apiary.create({
data: { name: `Stand ${Date.now()}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: {
code: `V-${Date.now()}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
const harvest = await prisma.harvest.create({
data: { apiaryId, date: new Date(), userId: beekeeperId },
});
harvestId = harvest.id;
await prisma.harvestColony.create({ data: { harvestId, colonyId } });
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId: beekeeperId },
});
extractionId = extraction.id;
await prisma.extractionHarvest.create({
data: { extractionId, harvestId },
});
const honeyBatch = await prisma.honeyBatch.create({
data: { batchNumber: `HC-${Date.now()}`, extractionId, amount: 25 },
});
honeyBatchId = honeyBatch.id;
});
afterAll(async () => {
if (bottlingBatchId) {
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingBatchId } });
}
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
await prisma.extractionHarvest.deleteMany({ where: { extractionId } });
await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.harvestColony.deleteMany({ where: { harvestId } });
await prisma.harvest.deleteMany({ where: { id: harvestId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
await prisma.user.deleteMany({ where: { id: beekeeperId } });
await app.close();
});
it('rejects a bottling amount exceeding the available honey batch amount', () => {
return request(app.getHttpServer())
.post('/api/v1/bottling-batches')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
honeyBatchId,
date: new Date().toISOString(),
jarSize: 500,
jarCount: 1000,
bestBeforeDate: new Date().toISOString(),
lotNumber: `L-${Date.now()}`,
})
.expect(400);
});
it('bottles a honey batch and reduces its remaining amount', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/bottling-batches')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
honeyBatchId,
date: new Date().toISOString(),
jarSize: 500,
jarCount: 50,
bestBeforeDate: new Date().toISOString(),
lotNumber: `L-${Date.now()}`,
})
.expect(201);
const bottlingBatch = response.body as BottlingBatchBody;
bottlingBatchId = bottlingBatch.id;
expect(bottlingBatch.totalAmount).toBe(25);
const honeyBatchResponse = await request(app.getHttpServer())
.get(`/api/v1/honey-batches/${honeyBatchId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const honeyBatch = honeyBatchResponse.body as HoneyBatchBody;
expect(honeyBatch.amount).toBe(0);
expect(honeyBatch.status).toBe('BOTTLED');
});
});
+102
View File
@@ -0,0 +1,102 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface ProductBody {
id: string;
name: string;
status: string;
}
describe('Products (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `products-e2e-beekeeper-${Date.now()}@example.com`;
const staffEmail = `products-e2e-staff-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let staffId: string;
let beekeeperToken: string;
let staffToken: string;
let productId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
});
afterAll(async () => {
if (productId) {
await prisma.product.deleteMany({ where: { id: productId } });
}
await prisma.refreshToken.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [beekeeperId, staffId] } },
});
await app.close();
});
it('rejects create for a STAFF user', () => {
return request(app.getHttpServer())
.post('/api/v1/products')
.set('Authorization', `Bearer ${staffToken}`)
.send({ name: `Sommerhonig ${Date.now()}` })
.expect(403);
});
it('lets a BEEKEEPER create a product', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/products')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ name: `Sommerhonig ${Date.now()}`, salesUnit: 'Glas' })
.expect(201);
const product = response.body as ProductBody;
productId = product.id;
expect(product.status).toBe('ACTIVE');
});
it('lists products for any authenticated role', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/products')
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
const products = response.body as ProductBody[];
expect(products.some((product) => product.id === productId)).toBe(true);
});
it('updates a product status', async () => {
const response = await request(app.getHttpServer())
.patch(`/api/v1/products/${productId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ status: 'DISCONTINUED' })
.expect(200);
const product = response.body as ProductBody;
expect(product.status).toBe('DISCONTINUED');
});
});
+1 -1
View File
@@ -187,7 +187,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li
- Backend: `BottlingModule` (`BottlingBatch`), `ProductsModule` (`Product`); Abfüllung reduziert `HoneyBatch.amount` transaktional - Backend: `BottlingModule` (`BottlingBatch`), `ProductsModule` (`Product`); Abfüllung reduziert `HoneyBatch.amount` transaktional
- Endpunkte: `GET/POST /api/v1/bottling-batches`, `GET /api/v1/bottling-batches/:id`, `GET/POST /api/v1/products`, `GET/PATCH /api/v1/products/:id` - Endpunkte: `GET/POST /api/v1/bottling-batches`, `GET /api/v1/bottling-batches/:id`, `GET/POST /api/v1/products`, `GET/PATCH /api/v1/products/:id`
- Frontend: `/bottling/new`, `/bottling/:id`, `/products`, `/products/new` - Frontend: `/bottling/new`, `/bottling/:id`, `/products`, `/products/new`
- [ ] Backend-Modul fertig, getestet, grün, committet - [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet
### 24. Inventar ### 24. Inventar