Witajcie, kontrolerzy jakości! Konsul Caesar.js zauważył, że legion czasem próbuje przemycić do skarbca tributy wątpliwej jakości lub fałszywe mapy. Czas nauczyć się walidacji na poziomie bazy danych - ostatniej linii obrony przed złymi danymi!
W świecie legionariuszów mamy kilka linii obrony przed złymi danymi:
1// tribute.entity.ts
2import { Entity, PrimaryGeneratedColumn, Column, Check } from 'typeorm';
3
4@Entity('tributes')
5@Check('value_positive', '"value" > 0')
6@Check('value_reasonable', '"value" <= 1000000')
7@Check('valid_type', '"type" IN ('gold', 'silver', 'jewels', 'artifacts', 'weapons')')
8@Check('name_length', 'LENGTH("name") >= 3')
9export class Tribute {
10 @PrimaryGeneratedColumn()
11 id: number;
12
13 @Column({ length: 100 })
14 name: string;
15
16 @Column()
17 type: string;
18
19 @Column('decimal', { precision: 10, scale: 2 })
20 value: number;
21
22 @Column({ nullable: true })
23 description: string;
24
25 @Column({ default: false })
26 isCursed: boolean;
27
28 @Column('date')
29 collectedAt: Date;
30}
31
32// legionariusze.entity.ts
33@Entity('legionariuszes')
34@Check('tribute_count_non_negative', '"tributeCount" >= 0')
35@Check('tribute_value_non_negative', '"totalTributeValue" >= 0')
36@Check('tribute_consistency', '("tributeCount" = 0 AND "totalTributeValue" = 0) OR ("tributeCount" > 0 AND "totalTributeValue" > 0)')
37@Check('name_not_empty', 'LENGTH(TRIM("name")) > 0')
38@Check('valid_rank', '"rank" IN ('Legionary', 'Ballistarius', 'Speculator', 'Optio', 'Centurion')')
39export class Legionary {
40 @PrimaryGeneratedColumn()
41 id: number;
42
43 @Column({ length: 100 })
44 name: string;
45
46 @Column({ length: 50 })
47 rank: string;
48
49 @Column({ type: 'int', default: 0 })
50 tributeCount: number;
51
52 @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
53 totalTributeValue: number;
54
55 @Column({ default: true })
56 isActive: boolean;
57
58 @Column({ type: 'date', nullable: true })
59 lastSeenAt: Date;
60}1-- Funkcja sprawdzająca czy email legionariusza jest unikalny
2CREATE OR REPLACE FUNCTION validate_legionariusze_email(email TEXT, legionariusze_id INT DEFAULT NULL)
3RETURNS BOOLEAN AS $$
4BEGIN
5 IF email IS NULL OR LENGTH(TRIM(email)) = 0 THEN
6 RETURN FALSE;
7 END IF;
8
9 IF email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,}$' THEN
10 RETURN FALSE;
11 END IF;
12
13 -- Sprawdź unikalność
14 IF EXISTS (
15 SELECT 1 FROM legionariuszes
16 WHERE email = validate_legionariusze_email.email
17 AND (legionariusze_id IS NULL OR id != legionariusze_id)
18 ) THEN
19 RETURN FALSE;
20 END IF;
21
22 RETURN TRUE;
23END;
24$$ LANGUAGE plpgsql;
25
26-- Funkcja sprawdzająca spójność danych legionariusza
27CREATE OR REPLACE FUNCTION validate_legionariusze_data(
28 tribute_count INT,
29 total_value DECIMAL,
30 rank TEXT
31) RETURNS BOOLEAN AS $$
32BEGIN
33 -- Jeśli brak tributów, wartość musi być 0
34 IF tribute_count = 0 AND total_value != 0 THEN
35 RETURN FALSE;
36 END IF;
37
38 -- Jeśli są tributy, wartość musi być > 0
39 IF tribute_count > 0 AND total_value <= 0 THEN
40 RETURN FALSE;
41 END IF;
42
43 -- Centurion musi mieć przynajmniej 10 tributów
44 IF rank = 'Centurion' AND tribute_count < 10 THEN
45 RETURN FALSE;
46 END IF;
47
48 -- Średnia wartość tributy nie może przekraczać 100,000
49 IF tribute_count > 0 AND (total_value / tribute_count) > 100000 THEN
50 RETURN FALSE;
51 END IF;
52
53 RETURN TRUE;
54END;
55$$ LANGUAGE plpgsql;1// Użycie custom function w Check constraint
2@Entity('legionariuszes')
3@Check('valid_legionariusze_data', 'validate_legionariusze_data("tributeCount", "totalTributeValue", "rank")')
4export class Legionary {
5 // ... pola entity
6}1-- Trigger sprawdzający spójność przy aktualizacji
2CREATE OR REPLACE FUNCTION validate_legionariusze_update()
3RETURNS TRIGGER AS $$
4BEGIN
5 -- Sprawdź czy legionariusz nie próbuje zmienić rangi na niższą
6 IF OLD.rank = 'Centurion' AND NEW.rank != 'Centurion' THEN
7 -- Sprawdź czy ma legion pod sobą
8 IF EXISTS (SELECT 1 FROM legionariuszes WHERE centurion_id = NEW.id) THEN
9 RAISE EXCEPTION 'Cannot demote centurion who has legion members';
10 END IF;
11 END IF;
12
13 -- Sprawdź czy liczba tributów się zgadza z rzeczywistością
14 DECLARE
15 actual_tribute_count INT;
16 actual_tribute_value DECIMAL;
17 BEGIN
18 SELECT COUNT(*), COALESCE(SUM(value), 0)
19 INTO actual_tribute_count, actual_tribute_value
20 FROM tributes
21 WHERE legionariusze_id = NEW.id;
22
23 IF NEW.tribute_count != actual_tribute_count THEN
24 RAISE EXCEPTION 'Tribute count mismatch: expected %, got %',
25 actual_tribute_count, NEW.tribute_count;
26 END IF;
27
28 IF ABS(NEW.total_tribute_value - actual_tribute_value) > 0.01 THEN
29 RAISE EXCEPTION 'Tribute value mismatch: expected %, got %',
30 actual_tribute_value, NEW.total_tribute_value;
31 END IF;
32 END;
33
34 -- Sprawdź czy email jest unikalny
35 IF NEW.email IS NOT NULL AND NOT validate_legionariusze_email(NEW.email, NEW.id) THEN
36 RAISE EXCEPTION 'Invalid or duplicate email: %', NEW.email;
37 END IF;
38
39 RETURN NEW;
40END;
41$$ LANGUAGE plpgsql;
42
43CREATE TRIGGER legionariusze_validation_trigger
44 BEFORE UPDATE ON legionariuszes
45 FOR EACH ROW
46 EXECUTE FUNCTION validate_legionariusze_update();1// tribute-validation.service.ts
2import { Injectable, BadRequestException } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository, DataSource } from 'typeorm';
5import { Tribute } from './tribute.entity';
6import { Legionary } from './legionariusze.entity';
7
8@Injectable()
9export class TributeValidationService {
10 constructor(
11 @InjectRepository(Tribute)
12 private tributeRepository: Repository<Tribute>,
13 @InjectRepository(Legionary)
14 private legionariuszeRepository: Repository<Legionary>,
15 private dataSource: DataSource
16 ) {}
17
18 async validateTributeCreation(tributeData: any): Promise<void> {
19 // 1. Sprawdzenie podstawowych reguł biznesowych
20 await this.validateTributeBusinessRules(tributeData);
21
22 // 2. Sprawdzenie unikalności nazwy w lokacji
23 await this.validateTributeUniqueness(tributeData);
24
25 // 3. Sprawdzenie czy legionariusz może posiadać więcej tributów
26 if (tributeData.legionariuszeId) {
27 await this.validateLegionaryCapacity(tributeData.legionariuszeId);
28 }
29
30 // 4. Sprawdzenie czy wartość jest realistyczna
31 await this.validateTributeValue(tributeData);
32 }
33
34 private async validateTributeBusinessRules(tributeData: any): Promise<void> {
35 // Przeklęte tributy nie mogą być zbyt cenne
36 if (tributeData.isCursed && tributeData.value > 10000) {
37 throw new BadRequestException('Cursed tributes cannot be worth more than 10,000 gold');
38 }
39
40 // Artefakty muszą mieć opis
41 if (tributeData.type === 'artifacts' && !tributeData.description) {
42 throw new BadRequestException('Artifacts must have a description');
43 }
44
45 // tributy nie mogą być odkryte w przyszłości
46 if (tributeData.collectedAt && new Date(tributeData.collectedAt) > new Date()) {
47 throw new BadRequestException('Tribute cannot be discovered in the future');
48 }
49
50 // Złoto musi być warte przynajmniej 100
51 if (tributeData.type === 'gold' && tributeData.value < 100) {
52 throw new BadRequestException('Gold tributes must be worth at least 100 gold');
53 }
54 }
55
56 private async validateTributeUniqueness(tributeData: any): Promise<void> {
57 const existingTribute = await this.tributeRepository.findOne({
58 where: {
59 name: tributeData.name,
60 location: tributeData.location
61 }
62 });
63
64 if (existingTribute) {
65 throw new BadRequestException(
66 `Tribute '${tributeData.name}' already exists in ${tributeData.location}`
67 );
68 }
69 }
70
71 private async validateLegionaryCapacity(legionariuszeId: number): Promise<void> {
72 const legionariusze = await this.legionariuszeRepository.findOne({ where: { id: legionariuszeId } });
73
74 if (!legionariusze) {
75 throw new BadRequestException('Legionary not found');
76 }
77
78 // Sprawdź limity według rangi
79 const rankLimits = {
80 'Legionary': 5,
81 'Ballistarius': 10,
82 'Speculator': 15,
83 'Optio': 25,
84 'Centurion': 100
85 };
86
87 const maxTributes = rankLimits[legionariusze.rank] || 5;
88
89 if (legionariusze.tributeCount >= maxTributes) {
90 throw new BadRequestException(
91 `Legionary of rank '${legionariusze.rank}' cannot own more than ${maxTributes} tributes`
92 );
93 }
94 }
95
96 private async validateTributeValue(tributeData: any): Promise<void> {
97 // Sprawdź średnią wartość dla typu tributy
98 const avgValue = await this.tributeRepository
99 .createQueryBuilder('tribute')
100 .select('AVG(tribute.value)', 'avg')
101 .where('tribute.type = :type', { type: tributeData.type })
102 .getRawOne();
103
104 if (avgValue && avgValue.avg) {
105 const average = parseFloat(avgValue.avg);
106
107 // tributy nie może być 10x droższy od średniej
108 if (tributeData.value > average * 10) {
109 throw new BadRequestException(
110 `Tribute value (${tributeData.value}) is too high compared to average for ${tributeData.type} (${average.toFixed(2)})`
111 );
112 }
113 }
114 }
115
116 // Walidacja aktualizacji legionu
117 async validateLegionUpdate(legionariuszeId: number, newCenturionId: number | null): Promise<void> {
118 const legionariusze = await this.legionariuszeRepository.findOne({
119 where: { id: legionariuszeId },
120 relations: ['centurion']
121 });
122
123 if (!legionariusze) {
124 throw new BadRequestException('Legionary not found');
125 }
126
127 // Jeśli przypisywanie do nowego centuriona
128 if (newCenturionId) {
129 const centurion = await this.legionariuszeRepository.findOne({
130 where: { id: newCenturionId, rank: 'Centurion', isActive: true }
131 });
132
133 if (!centurion) {
134 throw new BadRequestException('Centurion not found or inactive');
135 }
136
137 // Sprawdź czy centurion nie ma za dużo legionu
138 const legionCount = await this.legionariuszeRepository.count({
139 where: { centurion: { id: newCenturionId } }
140 });
141
142 if (legionCount >= 20) {
143 throw new BadRequestException('Centurion already has maximum legion size (20)');
144 }
145 }
146
147 // Centurion nie może być przypisany do innego centuriona
148 if (legionariusze.rank === 'Centurion' && newCenturionId) {
149 throw new BadRequestException('Centurions cannot be assigned to other centurions');
150 }
151 }
152}1// complex-validation.service.ts
2@Injectable()
3export class ComplexValidationService {
4 constructor(private dataSource: DataSource) {}
5
6 // Walidacja całej kohorty
7 async validateLegionIntegrity(cohortId: number): Promise<ValidationResult> {
8 const errors: string[] = [];
9 const warnings: string[] = [];
10
11 await this.dataSource.transaction(async manager => {
12 // 1. Sprawdź czy kohorta ma centuriona
13 const centurion = await manager.findOne(Legionary, {
14 where: { cohort: { id: cohortId }, rank: 'Centurion' }
15 });
16
17 if (!centurion) {
18 errors.push('Legion must have a centurion');
19 }
20
21 // 2. Sprawdź czy legion nie przekracza pojemności
22 const legionCount = await manager.count(Legionary, {
23 where: { cohort: { id: cohortId }, isActive: true }
24 });
25
26 const cohort = await manager.findOne(Legion, { where: { id: cohortId } });
27
28 if (cohort && legionCount > cohort.maxLegionSize) {
29 errors.push(`Legion size (${legionCount}) exceeds cohort capacity (${cohort.maxLegionSize})`);
30 }
31
32 // 3. Sprawdź bilans ról w legionie
33 const roleCount = await manager
34 .createQueryBuilder(Legionary, 'legionariusze')
35 .select('legionariusze.rank', 'rank')
36 .addSelect('COUNT(*)', 'count')
37 .where('legionariusze.cohort.id = :cohortId', { cohortId })
38 .andWhere('legionariusze.isActive = true')
39 .groupBy('legionariusze.rank')
40 .getRawMany();
41
42 const roles = roleCount.reduce((acc, item) => {
43 acc[item.rank] = parseInt(item.count);
44 return acc;
45 }, {});
46
47 // Sprawdź minimalne wymagania
48 if (!roles['Speculator'] || roles['Speculator'] < 1) {
49 warnings.push('Legion should have at least one Speculator');
50 }
51
52 if (!roles['Ballistarius'] || roles['Ballistarius'] < 2) {
53 warnings.push('Legion should have at least two Ballistarii');
54 }
55
56 if (roles['Centurion'] && roles['Centurion'] > 1) {
57 errors.push('Legion cannot have more than one Centurion');
58 }
59
60 // 4. Sprawdź czy tributy są bezpiecznie przechowywane
61 const totalTributeValue = await manager
62 .createQueryBuilder(Tribute, 'tribute')
63 .leftJoin('tribute.legionariusze', 'legionariusze')
64 .select('SUM(tribute.value)', 'total')
65 .where('legionariusze.cohort.id = :cohortId', { cohortId })
66 .getRawOne();
67
68 const resourceValue = parseFloat(totalTributeValue.total) || 0;
69
70 if (cohort && resourceValue > cohort.maxTributeCapacity) {
71 errors.push(
72 `Total tribute value (${resourceValue}) exceeds cohort capacity (${cohort.maxTributeCapacity})`
73 );
74 }
75 });
76
77 return {
78 isValid: errors.length === 0,
79 errors,
80 warnings
81 };
82 }
83
84 // Walidacja ekonomiczna legionu
85 async validateLegionEconomics(centurionId: number): Promise<EconomicValidationResult> {
86 const result = await this.dataSource
87 .createQueryBuilder(Legionary, 'legionariusze')
88 .select([
89 'COUNT(*) as legion_size',
90 'SUM(legionariusze.totalTributeValue) as total_legion_value',
91 'AVG(legionariusze.totalTributeValue) as avg_legionariusze_value',
92 'MIN(legionariusze.totalTributeValue) as min_legionariusze_value',
93 'MAX(legionariusze.totalTributeValue) as max_legionariusze_value'
94 ])
95 .where('legionariusze.centurion.id = :centurionId', { centurionId })
96 .andWhere('legionariusze.isActive = true')
97 .getRawOne();
98
99 const economics = {
100 legionSize: parseInt(result.legion_size),
101 totalValue: parseFloat(result.total_legion_value) || 0,
102 avgValue: parseFloat(result.avg_legionariusze_value) || 0,
103 minValue: parseFloat(result.min_legionariusze_value) || 0,
104 maxValue: parseFloat(result.max_legionariusze_value) || 0
105 };
106
107 const issues: string[] = [];
108
109 // Sprawdź nierówności w legionie
110 const inequality = economics.maxValue / Math.max(economics.minValue, 1);
111 if (inequality > 20) {
112 issues.push('Large wealth inequality in legion may cause mutiny');
113 }
114
115 // Sprawdź czy legion ma wystarczającą wartość na wyprawę
116 const minValuePerLegionary = 1000;
117 if (economics.avgValue < minValuePerLegionary) {
118 issues.push(`Average legionariusze value (${economics.avgValue}) below recommended minimum (${minValuePerLegionary})`);
119 }
120
121 return {
122 economics,
123 issues,
124 recommendations: this.generateEconomicRecommendations(economics)
125 };
126 }
127
128 private generateEconomicRecommendations(economics: any): string[] {
129 const recommendations: string[] = [];
130
131 if (economics.legionSize < 5) {
132 recommendations.push('Consider recruiting more legion members');
133 }
134
135 if (economics.avgValue < 5000) {
136 recommendations.push('Organize tribute hunting expeditions to increase legion wealth');
137 }
138
139 if (economics.totalValue > 100000) {
140 recommendations.push('Consider investing some tribute in cohort upgrades');
141 }
142
143 return recommendations;
144 }
145}
146
147interface ValidationResult {
148 isValid: boolean;
149 errors: string[];
150 warnings: string[];
151}
152
153interface EconomicValidationResult {
154 economics: {
155 legionSize: number;
156 totalValue: number;
157 avgValue: number;
158 minValue: number;
159 maxValue: number;
160 };
161 issues: string[];
162 recommendations: string[];
163}1// real-time-validation.service.ts
2@Injectable()
3export class RealTimeValidationService {
4 private validationCache = new Map<string, any>();
5 private cacheTimeout = 5 * 60 * 1000; // 5 minut
6
7 constructor(private dataSource: DataSource) {}
8
9 async validateInRealTime(entityType: string, data: any): Promise<ValidationResult> {
10 const cacheKey = `${entityType}_${JSON.stringify(data)}`1
2 // Sprawdź cache
3 const cached = this.validationCache.get(cacheKey);
4 if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
5 return cached.result;
6 }
7
8 // Wykonaj walidację
9 let result: ValidationResult;
10
11 switch (entityType) {
12 case 'tribute':
13 result = await this.validateTributeRealTime(data);
14 break;
15 case 'legionariusze':
16 result = await this.validateLegionaryRealTime(data);
17 break;
18 default:
19 result = { isValid: true, errors: [], warnings: [] };
20 }
21
22 // Zapisz w cache
23 this.validationCache.set(cacheKey, {
24 result,
25 timestamp: Date.now()
26 });
27
28 return result;
29 }
30
31 private async validateTributeRealTime(data: any): Promise<ValidationResult> {
32 const errors: string[] = [];
33 const warnings: string[] = [];
34
35 // Szybkie sprawdzenia bez zapytań do bazy
36 if (data.value <= 0) {
37 errors.push('Tribute value must be positive');
38 }
39
40 if (data.value > 1000000) {
41 warnings.push('Extremely valuable tribute - verify authenticity');
42 }
43
44 if (data.name && data.name.length < 3) {
45 errors.push('Tribute name too short');
46 }
47
48 // Sprawdzenia wymagające bazy danych (z timeoutem)
49 try {
50 const existsPromise = this.dataSource.getRepository(Tribute)
51 .count({ where: { name: data.name, location: data.location } });
52
53 const exists = await Promise.race([
54 existsPromise,
55 new Promise((_, reject) =>
56 setTimeout(() => reject(new Error('Timeout')), 2000)
57 )
58 ]) as number;
59
60 if (exists > 0) {
61 errors.push('Tribute with this name already exists in this location');
62 }
63 } catch (error) {
64 warnings.push('Could not verify tribute uniqueness - validation timeout');
65 }
66
67 return {
68 isValid: errors.length === 0,
69 errors,
70 warnings
71 };
72 }
73
74 private async validateLegionaryRealTime(data: any): Promise<ValidationResult> {
75 const errors: string[] = [];
76 const warnings: string[] = [];
77
78 // Podstawowe sprawdzenia
79 if (!data.name || data.name.trim().length === 0) {
80 errors.push('Legionary name is required');
81 }
82
83 if (data.tributeCount < 0) {
84 errors.push('Tribute count cannot be negative');
85 }
86
87 if (data.totalTributeValue < 0) {
88 errors.push('Total tribute value cannot be negative');
89 }
90
91 // Sprawdzenie spójności
92 if (data.tributeCount === 0 && data.totalTributeValue > 0) {
93 errors.push('Cannot have tribute value without tributes');
94 }
95
96 if (data.tributeCount > 0 && data.totalTributeValue === 0) {
97 warnings.push('Legionary has tributes but no value recorded');
98 }
99
100 return {
101 isValid: errors.length === 0,
102 errors,
103 warnings
104 };
105 }
106
107 clearCache(): void {
108 this.validationCache.clear();
109 }
110
111 getCacheStats(): any {
112 return {
113 size: this.validationCache.size,
114 entries: Array.from(this.validationCache.keys())
115 };
116 }
117}1// tribute-validation.service.spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { getRepositoryToken } from '@nestjs/typeorm';
4import { TributeValidationService } from './tribute-validation.service';
5import { Tribute } from './tribute.entity';
6import { Legionary } from './legionariusze.entity';
7
8describe('TributeValidationService', () => {
9 let service: TributeValidationService;
10 let mockTributeRepository: any;
11 let mockLegionaryRepository: any;
12
13 beforeEach(async () => {
14 mockTributeRepository = {
15 findOne: jest.fn(),
16 createQueryBuilder: jest.fn()
17 };
18
19 mockLegionaryRepository = {
20 findOne: jest.fn()
21 };
22
23 const module: TestingModule = await Test.createTestingModule({
24 providers: [
25 TributeValidationService,
26 { provide: getRepositoryToken(Tribute), useValue: mockTributeRepository },
27 { provide: getRepositoryToken(Legionary), useValue: mockLegionaryRepository },
28 { provide: DataSource, useValue: {} }
29 ]
30 }).compile();
31
32 service = module.get<TributeValidationService>(TributeValidationService);
33 });
34
35 describe('validateTributeCreation', () => {
36 it('should reject cursed tribute with high value', async () => {
37 const tributeData = {
38 name: 'Cursed Gold',
39 type: 'gold',
40 value: 15000,
41 isCursed: true
42 };
43
44 await expect(
45 service.validateTributeCreation(tributeData)
46 ).rejects.toThrow('Cursed tributes cannot be worth more than 10,000 gold');
47 });
48
49 it('should reject artifacts without description', async () => {
50 const tributeData = {
51 name: 'Ancient Relic',
52 type: 'artifacts',
53 value: 5000
54 };
55
56 await expect(
57 service.validateTributeCreation(tributeData)
58 ).rejects.toThrow('Artifacts must have a description');
59 });
60
61 it('should reject duplicate tribute names in same location', async () => {
62 const tributeData = {
63 name: 'Golden Coin',
64 location: 'Capua',
65 type: 'gold',
66 value: 1000
67 };
68
69 mockTributeRepository.findOne.mockResolvedValue({ id: 1 });
70
71 await expect(
72 service.validateTributeCreation(tributeData)
73 ).rejects.toThrow('already exists in Capua');
74 });
75 });
76});1// 1. Walidacja na poziomie DTO (szybka)
2// 2. Walidacja biznesowa w serwisie (kompleksowa)
3// 3. Walidacja w bazie danych (ostateczna)1// DOBRZE
2throw new BadRequestException(
3 'Legionary of rank 'Legionary' cannot own more than 5 tributes. Current: 7'
4);
5
6// ŹLE
7throw new BadRequestException('Invalid data');1// Cache wyniki kosztownych walidacji
2// Invaliduj cache przy zmianie danychGratulacje! Teraz jesteś mistrzem kontroli jakości danych! Umiesz:
✓ Implementować Check Constraints w entity ✓ Tworzyć custom funkcje SQL dla walidacji ✓ Używać triggerów do złożonych sprawdzeń ✓ Budować serwisy walidacyjne z logika biznesową ✓ Optymalizować walidację z cache'owaniem
Teraz żadne fałszywe tributy nie przedostaną się do Twojej tributów!