- Set up Jest testing framework with TypeScript support - Add unit tests for TransportChain blockchain (tracker, carbon, types) - Add unit tests for DemandForecaster (forecaster, aggregation, recommendations) - Add unit tests for VerticalFarmController (controller, recipes, environment) - Add API tests for transport, demand, and vertical-farm endpoints - Add integration tests for full lifecycle workflows: - Seed-to-seed lifecycle - Demand-to-harvest flow - VF batch lifecycle
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
/**
|
|
* TransportChain Tests
|
|
* Tests for the transport tracking blockchain implementation
|
|
*/
|
|
|
|
import {
|
|
TransportChain,
|
|
getTransportChain,
|
|
setTransportChain,
|
|
} from '../../../lib/transport/tracker';
|
|
import {
|
|
SeedAcquisitionEvent,
|
|
PlantingEvent,
|
|
GrowingTransportEvent,
|
|
HarvestEvent,
|
|
SeedSavingEvent,
|
|
TransportLocation,
|
|
CARBON_FACTORS,
|
|
} from '../../../lib/transport/types';
|
|
|
|
describe('TransportChain', () => {
|
|
let chain: TransportChain;
|
|
|
|
beforeEach(() => {
|
|
chain = new TransportChain(2); // Lower difficulty for faster tests
|
|
});
|
|
|
|
describe('Initialization', () => {
|
|
it('should create genesis block on initialization', () => {
|
|
expect(chain.chain.length).toBe(1);
|
|
expect(chain.chain[0].index).toBe(0);
|
|
expect(chain.chain[0].previousHash).toBe('0');
|
|
expect(chain.chain[0].transportEvent.eventType).toBe('seed_acquisition');
|
|
});
|
|
|
|
it('should set default difficulty', () => {
|
|
const defaultChain = new TransportChain();
|
|
expect(defaultChain.difficulty).toBe(3);
|
|
});
|
|
|
|
it('should allow custom difficulty', () => {
|
|
expect(chain.difficulty).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('Recording Events', () => {
|
|
it('should record seed acquisition event', () => {
|
|
const event: SeedAcquisitionEvent = createSeedAcquisitionEvent();
|
|
const block = chain.recordEvent(event);
|
|
|
|
expect(block.index).toBe(1);
|
|
expect(block.transportEvent.eventType).toBe('seed_acquisition');
|
|
expect(chain.chain.length).toBe(2);
|
|
});
|
|
|
|
it('should record planting event', () => {
|
|
const seedEvent = createSeedAcquisitionEvent();
|
|
chain.recordEvent(seedEvent);
|
|
|
|
const plantingEvent: PlantingEvent = createPlantingEvent();
|
|
const block = chain.recordEvent(plantingEvent);
|
|
|
|
expect(block.transportEvent.eventType).toBe('planting');
|
|
expect((block.transportEvent as PlantingEvent).plantIds.length).toBe(2);
|
|
});
|
|
|
|
it('should record growing transport event', () => {
|
|
const seedEvent = createSeedAcquisitionEvent();
|
|
chain.recordEvent(seedEvent);
|
|
|
|
const plantingEvent = createPlantingEvent();
|
|
chain.recordEvent(plantingEvent);
|
|
|
|
const growingEvent: GrowingTransportEvent = {
|
|
id: 'growing-001',
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'growing_transport',
|
|
fromLocation: createLocation('greenhouse'),
|
|
toLocation: createLocation('farm'),
|
|
distanceKm: 5,
|
|
durationMinutes: 30,
|
|
transportMethod: 'electric_vehicle',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'grower-1',
|
|
receiverId: 'grower-1',
|
|
status: 'verified',
|
|
plantIds: ['plant-001', 'plant-002'],
|
|
reason: 'transplant',
|
|
plantStage: 'seedling',
|
|
handlingMethod: 'potted',
|
|
rootDisturbance: 'minimal',
|
|
acclimatizationRequired: true,
|
|
acclimatizationDays: 3,
|
|
};
|
|
|
|
const block = chain.recordEvent(growingEvent);
|
|
expect(block.transportEvent.eventType).toBe('growing_transport');
|
|
});
|
|
|
|
it('should record harvest event', () => {
|
|
const harvestEvent: HarvestEvent = {
|
|
id: 'harvest-001',
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'harvest',
|
|
fromLocation: createLocation('farm'),
|
|
toLocation: createLocation('warehouse'),
|
|
distanceKm: 10,
|
|
durationMinutes: 45,
|
|
transportMethod: 'electric_truck',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'grower-1',
|
|
receiverId: 'distributor-1',
|
|
status: 'verified',
|
|
plantIds: ['plant-001', 'plant-002'],
|
|
harvestBatchId: 'harvest-batch-001',
|
|
harvestType: 'full',
|
|
produceType: 'tomatoes',
|
|
grossWeight: 10,
|
|
netWeight: 9.5,
|
|
weightUnit: 'kg',
|
|
packagingType: 'crates',
|
|
temperatureRequired: { min: 10, max: 15, optimal: 12, unit: 'celsius' },
|
|
shelfLifeHours: 168,
|
|
seedsSaved: false,
|
|
};
|
|
|
|
const block = chain.recordEvent(harvestEvent);
|
|
expect(block.transportEvent.eventType).toBe('harvest');
|
|
expect((block.transportEvent as HarvestEvent).netWeight).toBe(9.5);
|
|
});
|
|
|
|
it('should accumulate carbon footprint across blocks', () => {
|
|
const event1 = createSeedAcquisitionEvent();
|
|
event1.distanceKm = 10;
|
|
event1.transportMethod = 'gasoline_vehicle';
|
|
const block1 = chain.recordEvent(event1);
|
|
|
|
const event2 = createSeedAcquisitionEvent();
|
|
event2.id = 'seed-002';
|
|
event2.distanceKm = 20;
|
|
event2.transportMethod = 'diesel_truck';
|
|
const block2 = chain.recordEvent(event2);
|
|
|
|
expect(block2.cumulativeCarbonKg).toBeGreaterThan(block1.cumulativeCarbonKg);
|
|
});
|
|
|
|
it('should accumulate food miles across blocks', () => {
|
|
const event1 = createSeedAcquisitionEvent();
|
|
event1.distanceKm = 10;
|
|
const block1 = chain.recordEvent(event1);
|
|
|
|
const event2 = createSeedAcquisitionEvent();
|
|
event2.id = 'seed-002';
|
|
event2.distanceKm = 20;
|
|
const block2 = chain.recordEvent(event2);
|
|
|
|
expect(block2.cumulativeFoodMiles).toBe(
|
|
block1.cumulativeFoodMiles + 20
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Chain Integrity', () => {
|
|
it('should verify chain integrity', () => {
|
|
chain.recordEvent(createSeedAcquisitionEvent());
|
|
chain.recordEvent(createPlantingEvent());
|
|
|
|
expect(chain.isChainValid()).toBe(true);
|
|
});
|
|
|
|
it('should detect tampered blocks', () => {
|
|
chain.recordEvent(createSeedAcquisitionEvent());
|
|
|
|
// Tamper with the chain
|
|
chain.chain[1].transportEvent.distanceKm = 999;
|
|
|
|
expect(chain.isChainValid()).toBe(false);
|
|
});
|
|
|
|
it('should detect broken chain links', () => {
|
|
chain.recordEvent(createSeedAcquisitionEvent());
|
|
chain.recordEvent(createPlantingEvent());
|
|
|
|
// Break the chain link
|
|
chain.chain[2].previousHash = 'fake-hash';
|
|
|
|
expect(chain.isChainValid()).toBe(false);
|
|
});
|
|
|
|
it('should maintain proper hash linking', () => {
|
|
chain.recordEvent(createSeedAcquisitionEvent());
|
|
chain.recordEvent(createPlantingEvent());
|
|
|
|
for (let i = 1; i < chain.chain.length; i++) {
|
|
expect(chain.chain[i].previousHash).toBe(chain.chain[i - 1].hash);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Plant Journey', () => {
|
|
it('should track plant journey across events', () => {
|
|
const plantId = 'plant-001';
|
|
|
|
// Record planting
|
|
const plantingEvent = createPlantingEvent();
|
|
plantingEvent.plantIds = [plantId];
|
|
chain.recordEvent(plantingEvent);
|
|
|
|
// Record growing transport
|
|
const growingEvent: GrowingTransportEvent = {
|
|
id: 'growing-001',
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'growing_transport',
|
|
fromLocation: createLocation('greenhouse'),
|
|
toLocation: createLocation('farm'),
|
|
distanceKm: 5,
|
|
durationMinutes: 30,
|
|
transportMethod: 'walking',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'grower-1',
|
|
receiverId: 'grower-1',
|
|
status: 'verified',
|
|
plantIds: [plantId],
|
|
reason: 'transplant',
|
|
plantStage: 'vegetative',
|
|
handlingMethod: 'potted',
|
|
rootDisturbance: 'minimal',
|
|
acclimatizationRequired: false,
|
|
};
|
|
chain.recordEvent(growingEvent);
|
|
|
|
const journey = chain.getPlantJourney(plantId);
|
|
expect(journey).not.toBeNull();
|
|
expect(journey?.plantId).toBe(plantId);
|
|
expect(journey?.events.length).toBe(2);
|
|
expect(journey?.totalFoodMiles).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return null for unknown plant', () => {
|
|
const journey = chain.getPlantJourney('unknown-plant');
|
|
expect(journey).toBeNull();
|
|
});
|
|
|
|
it('should calculate correct journey metrics', () => {
|
|
const plantId = 'plant-journey-test';
|
|
|
|
const plantingEvent = createPlantingEvent();
|
|
plantingEvent.plantIds = [plantId];
|
|
plantingEvent.distanceKm = 10;
|
|
plantingEvent.durationMinutes = 60;
|
|
chain.recordEvent(plantingEvent);
|
|
|
|
const journey = chain.getPlantJourney(plantId);
|
|
expect(journey?.totalFoodMiles).toBe(10);
|
|
expect(journey?.daysInTransit).toBe(0); // 60 min = ~0 days
|
|
});
|
|
|
|
it('should track current stage correctly', () => {
|
|
const plantId = 'stage-test-plant';
|
|
|
|
const plantingEvent = createPlantingEvent();
|
|
plantingEvent.plantIds = [plantId];
|
|
chain.recordEvent(plantingEvent);
|
|
|
|
let journey = chain.getPlantJourney(plantId);
|
|
expect(journey?.currentStage).toBe('seedling');
|
|
|
|
const growingEvent: GrowingTransportEvent = {
|
|
id: 'growing-stage',
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'growing_transport',
|
|
fromLocation: createLocation('greenhouse'),
|
|
toLocation: createLocation('farm'),
|
|
distanceKm: 1,
|
|
durationMinutes: 10,
|
|
transportMethod: 'walking',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'grower-1',
|
|
receiverId: 'grower-1',
|
|
status: 'verified',
|
|
plantIds: [plantId],
|
|
reason: 'relocation',
|
|
plantStage: 'flowering',
|
|
handlingMethod: 'potted',
|
|
rootDisturbance: 'none',
|
|
acclimatizationRequired: false,
|
|
};
|
|
chain.recordEvent(growingEvent);
|
|
|
|
journey = chain.getPlantJourney(plantId);
|
|
expect(journey?.currentStage).toBe('flowering');
|
|
});
|
|
});
|
|
|
|
describe('Environmental Impact', () => {
|
|
it('should calculate environmental impact for user', () => {
|
|
const userId = 'user-impact-test';
|
|
|
|
const event = createSeedAcquisitionEvent();
|
|
event.senderId = userId;
|
|
event.distanceKm = 50;
|
|
event.transportMethod = 'diesel_truck';
|
|
chain.recordEvent(event);
|
|
|
|
const impact = chain.getEnvironmentalImpact(userId);
|
|
expect(impact.totalFoodMiles).toBe(50);
|
|
expect(impact.totalCarbonKg).toBeGreaterThan(0);
|
|
expect(impact.breakdownByMethod['diesel_truck']).toBeDefined();
|
|
expect(impact.breakdownByEventType['seed_acquisition']).toBeDefined();
|
|
});
|
|
|
|
it('should compare to conventional agriculture', () => {
|
|
const userId = 'compare-test';
|
|
|
|
const event = createSeedAcquisitionEvent();
|
|
event.senderId = userId;
|
|
event.distanceKm = 10; // Very short distance
|
|
event.transportMethod = 'bicycle'; // Zero carbon
|
|
chain.recordEvent(event);
|
|
|
|
const impact = chain.getEnvironmentalImpact(userId);
|
|
expect(impact.comparisonToConventional.milesSaved).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return zero impact for user with no events', () => {
|
|
const impact = chain.getEnvironmentalImpact('nonexistent-user');
|
|
expect(impact.totalCarbonKg).toBe(0);
|
|
expect(impact.totalFoodMiles).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('QR Code Generation', () => {
|
|
it('should generate valid QR data for plant', () => {
|
|
const plantId = 'qr-test-plant';
|
|
|
|
const plantingEvent = createPlantingEvent();
|
|
plantingEvent.plantIds = [plantId];
|
|
chain.recordEvent(plantingEvent);
|
|
|
|
const qrData = chain.generateQRData(plantId, undefined);
|
|
expect(qrData.plantId).toBe(plantId);
|
|
expect(qrData.quickLookupUrl).toContain(plantId);
|
|
expect(qrData.lineageHash).toBeDefined();
|
|
expect(qrData.verificationCode).toMatch(/^[A-F0-9]{8}$/);
|
|
});
|
|
|
|
it('should generate QR data for batch', () => {
|
|
const batchId = 'seed-batch-001';
|
|
|
|
const event = createSeedAcquisitionEvent();
|
|
event.seedBatchId = batchId;
|
|
chain.recordEvent(event);
|
|
|
|
const qrData = chain.generateQRData(undefined, batchId);
|
|
expect(qrData.batchId).toBe(batchId);
|
|
expect(qrData.lastEventType).toBe('seed_acquisition');
|
|
});
|
|
|
|
it('should include blockchain address', () => {
|
|
const qrData = chain.generateQRData('any-id', undefined);
|
|
expect(qrData.blockchainAddress).toHaveLength(42);
|
|
});
|
|
});
|
|
|
|
describe('Serialization', () => {
|
|
it('should export to JSON', () => {
|
|
chain.recordEvent(createSeedAcquisitionEvent());
|
|
|
|
const json = chain.toJSON();
|
|
expect(json).toHaveProperty('difficulty');
|
|
expect(json).toHaveProperty('chain');
|
|
expect((json as any).chain.length).toBe(2);
|
|
});
|
|
|
|
it('should import from JSON', () => {
|
|
chain.recordEvent(createSeedAcquisitionEvent());
|
|
chain.recordEvent(createPlantingEvent());
|
|
|
|
const json = chain.toJSON();
|
|
const restored = TransportChain.fromJSON(json);
|
|
|
|
expect(restored.chain.length).toBe(chain.chain.length);
|
|
expect(restored.isChainValid()).toBe(true);
|
|
});
|
|
|
|
it('should rebuild indexes after import', () => {
|
|
const plantId = 'import-test-plant';
|
|
const plantingEvent = createPlantingEvent();
|
|
plantingEvent.plantIds = [plantId];
|
|
chain.recordEvent(plantingEvent);
|
|
|
|
const json = chain.toJSON();
|
|
const restored = TransportChain.fromJSON(json);
|
|
|
|
const journey = restored.getPlantJourney(plantId);
|
|
expect(journey).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Singleton', () => {
|
|
it('should return same instance from getTransportChain', () => {
|
|
const chain1 = getTransportChain();
|
|
const chain2 = getTransportChain();
|
|
expect(chain1).toBe(chain2);
|
|
});
|
|
|
|
it('should allow setting custom chain', () => {
|
|
const customChain = new TransportChain(1);
|
|
setTransportChain(customChain);
|
|
expect(getTransportChain()).toBe(customChain);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Helper functions
|
|
function createLocation(type: string): TransportLocation {
|
|
return {
|
|
latitude: 40.7128 + Math.random() * 0.1,
|
|
longitude: -74.006 + Math.random() * 0.1,
|
|
locationType: type as any,
|
|
facilityName: `Test ${type}`,
|
|
};
|
|
}
|
|
|
|
function createSeedAcquisitionEvent(): SeedAcquisitionEvent {
|
|
return {
|
|
id: `seed-${Date.now()}`,
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'seed_acquisition',
|
|
fromLocation: createLocation('seed_bank'),
|
|
toLocation: createLocation('greenhouse'),
|
|
distanceKm: 25,
|
|
durationMinutes: 45,
|
|
transportMethod: 'electric_vehicle',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'seed-bank-1',
|
|
receiverId: 'grower-1',
|
|
status: 'verified',
|
|
seedBatchId: 'seed-batch-001',
|
|
sourceType: 'seed_bank',
|
|
species: 'Solanum lycopersicum',
|
|
variety: 'Roma',
|
|
quantity: 100,
|
|
quantityUnit: 'seeds',
|
|
generation: 1,
|
|
germinationRate: 95,
|
|
certifications: ['organic', 'heirloom'],
|
|
};
|
|
}
|
|
|
|
function createPlantingEvent(): PlantingEvent {
|
|
return {
|
|
id: `planting-${Date.now()}`,
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'planting',
|
|
fromLocation: createLocation('greenhouse'),
|
|
toLocation: createLocation('greenhouse'),
|
|
distanceKm: 0.01,
|
|
durationMinutes: 5,
|
|
transportMethod: 'walking',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'grower-1',
|
|
receiverId: 'grower-1',
|
|
status: 'verified',
|
|
seedBatchId: 'seed-batch-001',
|
|
plantIds: ['plant-001', 'plant-002'],
|
|
plantingMethod: 'indoor_start',
|
|
quantityPlanted: 2,
|
|
growingEnvironment: 'greenhouse',
|
|
};
|
|
}
|