e-data 2.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1169 @@
1
+ """Tests for BillingService."""
2
+
3
+ import shutil
4
+ import tempfile
5
+ from datetime import datetime, timedelta
6
+ from unittest.mock import AsyncMock, Mock, patch
7
+
8
+ import pytest
9
+ import pytest_asyncio
10
+
11
+ from edata.models.pricing import PricingData, PricingRules
12
+ from edata.services.billing import BillingService
13
+
14
+
15
+ class TestBillingService:
16
+ """Test suite for BillingService."""
17
+
18
+ @pytest.fixture
19
+ def temp_dir(self):
20
+ """Create a temporary directory for tests."""
21
+ temp_dir = tempfile.mkdtemp()
22
+ yield temp_dir
23
+ shutil.rmtree(temp_dir)
24
+
25
+ @pytest.fixture
26
+ def mock_redata_connector(self):
27
+ """Mock REDataConnector for testing."""
28
+ with patch("edata.services.billing.REDataConnector") as mock_connector_class:
29
+ mock_connector = Mock()
30
+ mock_connector_class.return_value = mock_connector
31
+ yield mock_connector, mock_connector_class
32
+
33
+ @pytest.fixture
34
+ def mock_database_service(self):
35
+ """Mock DatabaseService for testing."""
36
+ with patch("edata.services.billing.get_database_service") as mock_get_db:
37
+ mock_db = Mock()
38
+ # Hacer que los métodos async retornen AsyncMock
39
+ mock_db.get_pvpc_prices = AsyncMock(return_value=[])
40
+ mock_db.save_pvpc_price = AsyncMock(return_value=Mock())
41
+ mock_db.get_billing = AsyncMock(return_value=[])
42
+ mock_db.save_billing = AsyncMock(return_value=Mock())
43
+ mock_db.get_consumptions = AsyncMock(return_value=[])
44
+ mock_db.get_contracts = AsyncMock(return_value=[])
45
+ mock_db.generate_pricing_config_hash = Mock(return_value="test_hash")
46
+ mock_db.get_latest_pvpc_price = AsyncMock(return_value=None)
47
+ mock_db.get_latest_billing = AsyncMock(return_value=None)
48
+ mock_get_db.return_value = mock_db
49
+ yield mock_db
50
+
51
+ @pytest_asyncio.fixture
52
+ async def billing_service(
53
+ self, temp_dir, mock_redata_connector, mock_database_service
54
+ ):
55
+ """Create a BillingService instance for testing."""
56
+ return BillingService(storage_dir=temp_dir)
57
+
58
+ @pytest.fixture
59
+ def sample_pvpc_prices(self):
60
+ """Sample PVPC price data for testing."""
61
+ return [
62
+ PricingData(
63
+ datetime=datetime(2024, 6, 17, 10, 0),
64
+ value_eur_kwh=0.12345,
65
+ delta_h=1.0,
66
+ ),
67
+ PricingData(
68
+ datetime=datetime(2024, 6, 17, 11, 0),
69
+ value_eur_kwh=0.13456,
70
+ delta_h=1.0,
71
+ ),
72
+ PricingData(
73
+ datetime=datetime(2024, 6, 17, 12, 0),
74
+ value_eur_kwh=0.14567,
75
+ delta_h=1.0,
76
+ ),
77
+ ]
78
+
79
+ @pytest.fixture
80
+ def sample_pricing_rules_pvpc(self):
81
+ """Sample pricing rules for PVPC configuration."""
82
+ return PricingRules(
83
+ p1_kw_year_eur=30.67,
84
+ p2_kw_year_eur=1.42,
85
+ p1_kwh_eur=None, # PVPC
86
+ p2_kwh_eur=None, # PVPC
87
+ p3_kwh_eur=None, # PVPC
88
+ surplus_p1_kwh_eur=0.05,
89
+ surplus_p2_kwh_eur=0.04,
90
+ surplus_p3_kwh_eur=0.03,
91
+ meter_month_eur=0.81,
92
+ market_kw_year_eur=3.11,
93
+ electricity_tax=1.05113,
94
+ iva_tax=1.21,
95
+ energy_formula="electricity_tax * iva_tax * kwh_eur * kwh",
96
+ power_formula="electricity_tax * iva_tax * (p1_kw * (p1_kw_year_eur + market_kw_year_eur) + p2_kw * p2_kw_year_eur) / 365 / 24",
97
+ others_formula="iva_tax * meter_month_eur / 30 / 24",
98
+ surplus_formula="electricity_tax * iva_tax * surplus_kwh * surplus_kwh_eur",
99
+ main_formula="energy_term + power_term + others_term",
100
+ )
101
+
102
+ @pytest.fixture
103
+ def sample_pricing_rules_custom(self):
104
+ """Sample pricing rules for custom pricing configuration."""
105
+ return PricingRules(
106
+ p1_kw_year_eur=30.67,
107
+ p2_kw_year_eur=1.42,
108
+ p1_kwh_eur=0.15, # Custom prices
109
+ p2_kwh_eur=0.12,
110
+ p3_kwh_eur=0.08,
111
+ surplus_p1_kwh_eur=0.05,
112
+ surplus_p2_kwh_eur=0.04,
113
+ surplus_p3_kwh_eur=0.03,
114
+ meter_month_eur=0.81,
115
+ market_kw_year_eur=3.11,
116
+ electricity_tax=1.05113,
117
+ iva_tax=1.21,
118
+ energy_formula="electricity_tax * iva_tax * kwh_eur * kwh",
119
+ power_formula="electricity_tax * iva_tax * (p1_kw * (p1_kw_year_eur + market_kw_year_eur) + p2_kw * p2_kw_year_eur) / 365 / 24",
120
+ others_formula="iva_tax * meter_month_eur / 30 / 24",
121
+ surplus_formula="electricity_tax * iva_tax * surplus_kwh * surplus_kwh_eur",
122
+ main_formula="energy_term + power_term + others_term",
123
+ )
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_initialization(
127
+ self, temp_dir, mock_redata_connector, mock_database_service
128
+ ):
129
+ """Test BillingService initialization."""
130
+ mock_connector, mock_connector_class = mock_redata_connector
131
+
132
+ service = BillingService(storage_dir=temp_dir)
133
+
134
+ # Verify REDataConnector was initialized
135
+ mock_connector_class.assert_called_once()
136
+
137
+ # Verify database service is obtained lazily by calling _get_db_service
138
+ db_service = await service._get_db_service()
139
+ assert db_service is mock_database_service
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_update_pvpc_prices_success(
143
+ self,
144
+ billing_service,
145
+ mock_redata_connector,
146
+ mock_database_service,
147
+ sample_pvpc_prices,
148
+ ):
149
+ """Test successful PVPC price update."""
150
+ mock_connector, mock_connector_class = mock_redata_connector
151
+ start_date = datetime(2024, 6, 17, 0, 0)
152
+ end_date = datetime(2024, 6, 17, 23, 59)
153
+
154
+ # Mock REData connector response
155
+ mock_connector.get_realtime_prices = AsyncMock(return_value=sample_pvpc_prices)
156
+
157
+ # Mock database service responses - no existing prices
158
+ mock_database_service.get_pvpc_prices.return_value = []
159
+
160
+ # Execute PVPC update
161
+ result = await billing_service.update_pvpc_prices(
162
+ start_date=start_date, end_date=end_date, is_ceuta_melilla=False
163
+ )
164
+
165
+ # Verify REData connector was called correctly
166
+ mock_connector.get_realtime_prices.assert_called_once_with(
167
+ dt_from=start_date, dt_to=end_date, is_ceuta_melilla=False
168
+ )
169
+
170
+ # Verify database service was called for each price
171
+ assert mock_database_service.save_pvpc_price.call_count == len(
172
+ sample_pvpc_prices
173
+ )
174
+
175
+ # Verify result structure
176
+ assert result["success"] is True
177
+ assert result["region"] == "Peninsula"
178
+ assert result["geo_id"] == 8741
179
+ assert result["stats"]["fetched"] == len(sample_pvpc_prices)
180
+ assert result["stats"]["saved"] == len(sample_pvpc_prices)
181
+ assert result["stats"]["updated"] == 0
182
+
183
+ @patch("edata.utils.get_pvpc_tariff")
184
+ def test_get_custom_prices_success(
185
+ self,
186
+ mock_get_pvpc_tariff,
187
+ billing_service,
188
+ mock_redata_connector,
189
+ mock_database_service,
190
+ sample_pricing_rules_custom,
191
+ ):
192
+ """Test successful custom price calculation."""
193
+ mock_connector, mock_connector_class = mock_redata_connector
194
+ start_date = datetime(2024, 6, 17, 10, 0) # Monday 10 AM
195
+ end_date = datetime(2024, 6, 17, 13, 0) # Monday 1 PM (3 hours)
196
+
197
+ # Mock tariff calculation to cycle through periods
198
+ mock_get_pvpc_tariff.side_effect = ["p1", "p1", "p1"] # All P1 hours
199
+
200
+ # Execute custom price calculation (not async)
201
+ result = billing_service.get_custom_prices(
202
+ pricing_rules=sample_pricing_rules_custom,
203
+ start_date=start_date,
204
+ end_date=end_date,
205
+ )
206
+
207
+ # Verify tariff function was called for each hour
208
+ assert mock_get_pvpc_tariff.call_count == 3
209
+
210
+ # Verify result structure
211
+ assert len(result) == 3
212
+ assert all(isinstance(price, PricingData) for price in result)
213
+ assert all(
214
+ price.value_eur_kwh == sample_pricing_rules_custom.p1_kwh_eur
215
+ for price in result
216
+ )
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_get_stored_pvpc_prices(
220
+ self, billing_service, mock_redata_connector, mock_database_service
221
+ ):
222
+ """Test getting stored PVPC prices from database."""
223
+ start_date = datetime(2024, 6, 17, 0, 0)
224
+ end_date = datetime(2024, 6, 17, 23, 59)
225
+ geo_id = 8741
226
+
227
+ # Mock database service response
228
+ mock_prices = [Mock(), Mock(), Mock()]
229
+ mock_database_service.get_pvpc_prices.return_value = mock_prices
230
+
231
+ # Execute get stored prices
232
+ result = await billing_service.get_stored_pvpc_prices(
233
+ start_date=start_date, end_date=end_date, geo_id=geo_id
234
+ )
235
+
236
+ # Verify database service was called correctly
237
+ mock_database_service.get_pvpc_prices.assert_called_once_with(
238
+ start_date, end_date, geo_id
239
+ )
240
+
241
+ # Verify result
242
+ assert result == mock_prices
243
+
244
+ @pytest.mark.asyncio
245
+ async def test_get_prices_pvpc(
246
+ self,
247
+ billing_service,
248
+ mock_redata_connector,
249
+ mock_database_service,
250
+ sample_pricing_rules_pvpc,
251
+ ):
252
+ """Test automatic price retrieval with PVPC configuration."""
253
+ mock_connector, mock_connector_class = mock_redata_connector
254
+ start_date = datetime(2024, 6, 17, 0, 0)
255
+ end_date = datetime(2024, 6, 17, 23, 59)
256
+
257
+ # Mock stored PVPC prices
258
+ mock_pvpc_prices = [
259
+ Mock(
260
+ datetime=datetime(2024, 6, 17, 10, 0), value_eur_kwh=0.15, delta_h=1.0
261
+ ),
262
+ Mock(
263
+ datetime=datetime(2024, 6, 17, 11, 0), value_eur_kwh=0.16, delta_h=1.0
264
+ ),
265
+ ]
266
+ mock_database_service.get_pvpc_prices.return_value = mock_pvpc_prices
267
+
268
+ # Execute automatic price retrieval with PVPC rules
269
+ result = await billing_service.get_prices(
270
+ pricing_rules=sample_pricing_rules_pvpc,
271
+ start_date=start_date,
272
+ end_date=end_date,
273
+ is_ceuta_melilla=False,
274
+ )
275
+
276
+ # Should call PVPC retrieval
277
+ mock_database_service.get_pvpc_prices.assert_called_once()
278
+ assert len(result) == 2
279
+ assert all(isinstance(price, PricingData) for price in result)
280
+
281
+ @patch("edata.utils.get_pvpc_tariff")
282
+ @pytest.mark.asyncio
283
+ async def test_get_prices_custom(
284
+ self,
285
+ mock_get_pvpc_tariff,
286
+ billing_service,
287
+ mock_redata_connector,
288
+ mock_database_service,
289
+ sample_pricing_rules_custom,
290
+ ):
291
+ """Test automatic price retrieval with custom configuration."""
292
+ mock_connector, mock_connector_class = mock_redata_connector
293
+ start_date = datetime(2024, 6, 17, 10, 0)
294
+ end_date = datetime(2024, 6, 17, 11, 0)
295
+
296
+ # Mock tariff calculation
297
+ mock_get_pvpc_tariff.return_value = "p1"
298
+
299
+ # Execute automatic price retrieval with custom rules
300
+ result = await billing_service.get_prices(
301
+ pricing_rules=sample_pricing_rules_custom,
302
+ start_date=start_date,
303
+ end_date=end_date,
304
+ )
305
+
306
+ # Should call custom calculation (not database)
307
+ mock_database_service.get_pvpc_prices.assert_not_called()
308
+ assert len(result) == 1
309
+ assert isinstance(result[0], PricingData)
310
+ assert result[0].value_eur_kwh == sample_pricing_rules_custom.p1_kwh_eur
311
+
312
+ @pytest.mark.asyncio
313
+ async def test_get_prices_pvpc_no_data(
314
+ self,
315
+ billing_service,
316
+ mock_redata_connector,
317
+ mock_database_service,
318
+ sample_pricing_rules_pvpc,
319
+ ):
320
+ """Test automatic price retrieval with PVPC configuration but no data."""
321
+ mock_connector, mock_connector_class = mock_redata_connector
322
+ start_date = datetime(2024, 6, 17, 0, 0)
323
+ end_date = datetime(2024, 6, 17, 23, 59)
324
+
325
+ # Mock no PVPC prices available
326
+ mock_database_service.get_pvpc_prices.return_value = []
327
+
328
+ # Execute automatic price retrieval with PVPC rules
329
+ result = await billing_service.get_prices(
330
+ pricing_rules=sample_pricing_rules_pvpc,
331
+ start_date=start_date,
332
+ end_date=end_date,
333
+ is_ceuta_melilla=False,
334
+ )
335
+
336
+ # Should return None when no data available
337
+ assert result is None
338
+ mock_database_service.get_pvpc_prices.assert_called_once()
339
+
340
+ @pytest.mark.asyncio
341
+ async def test_get_prices_custom_no_prices_defined(
342
+ self, billing_service, mock_redata_connector, mock_database_service
343
+ ):
344
+ """Test automatic price retrieval with custom configuration but no prices defined."""
345
+ from edata.models.pricing import PricingRules
346
+
347
+ mock_connector, mock_connector_class = mock_redata_connector
348
+ start_date = datetime(2024, 6, 17, 10, 0)
349
+ end_date = datetime(2024, 6, 17, 11, 0)
350
+
351
+ # Create pricing rules with no energy prices defined
352
+ empty_pricing_rules = PricingRules(
353
+ p1_kw_year_eur=30.67,
354
+ p2_kw_year_eur=1.42,
355
+ p1_kwh_eur=None, # No custom prices
356
+ p2_kwh_eur=None,
357
+ p3_kwh_eur=None,
358
+ surplus_p1_kwh_eur=0.05,
359
+ surplus_p2_kwh_eur=0.04,
360
+ surplus_p3_kwh_eur=0.03,
361
+ meter_month_eur=0.81,
362
+ market_kw_year_eur=3.11,
363
+ electricity_tax=1.05113,
364
+ iva_tax=1.21,
365
+ energy_formula="electricity_tax * iva_tax * kwh_eur * kwh",
366
+ power_formula="electricity_tax * iva_tax * (p1_kw * (p1_kw_year_eur + market_kw_year_eur) + p2_kw * p2_kw_year_eur) / 365 / 24",
367
+ others_formula="iva_tax * meter_month_eur / 30 / 24",
368
+ surplus_formula="electricity_tax * iva_tax * surplus_kwh * surplus_kwh_eur",
369
+ main_formula="energy_term + power_term + others_term",
370
+ )
371
+
372
+ # Mock empty PVPC prices since rules indicate PVPC usage
373
+ mock_database_service.get_pvpc_prices.return_value = []
374
+
375
+ # Execute automatic price retrieval with empty custom rules
376
+ result = await billing_service.get_prices(
377
+ pricing_rules=empty_pricing_rules, start_date=start_date, end_date=end_date
378
+ )
379
+
380
+ # Should return None when no PVPC prices available
381
+ assert result is None
382
+ # Should have tried to get PVPC prices since no custom prices are defined
383
+ mock_database_service.get_pvpc_prices.assert_called_once_with(
384
+ start_date, end_date, 8741
385
+ )
386
+
387
+ @pytest.mark.asyncio
388
+ @patch("edata.utils.get_pvpc_tariff")
389
+ async def test_get_cost_calculation(
390
+ self,
391
+ mock_get_pvpc_tariff,
392
+ billing_service,
393
+ mock_redata_connector,
394
+ mock_database_service,
395
+ sample_pricing_rules_custom,
396
+ ):
397
+ """Test cost calculation functionality."""
398
+ from datetime import datetime
399
+
400
+ from edata.models.pricing import PricingAggregated
401
+
402
+ mock_connector, mock_connector_class = mock_redata_connector
403
+ cups = "ES0123456789012345AB"
404
+ start_date = datetime(2024, 6, 17, 10, 0)
405
+ end_date = datetime(2024, 6, 17, 12, 0) # 2 hours
406
+
407
+ # Mock no existing billing data initially
408
+ mock_database_service.get_billing.return_value = []
409
+
410
+ # Mock the pricing config hash generation
411
+ mock_database_service.generate_pricing_config_hash.return_value = (
412
+ "test_hash_12345678"
413
+ )
414
+
415
+ # Mock consumption data
416
+ mock_consumptions = [
417
+ type(
418
+ "MockConsumption",
419
+ (),
420
+ {
421
+ "datetime": datetime(2024, 6, 17, 10, 0),
422
+ "value_kwh": 0.5,
423
+ "surplus_kwh": 0.0,
424
+ },
425
+ )(),
426
+ type(
427
+ "MockConsumption",
428
+ (),
429
+ {
430
+ "datetime": datetime(2024, 6, 17, 11, 0),
431
+ "value_kwh": 0.6,
432
+ "surplus_kwh": 0.0,
433
+ },
434
+ )(),
435
+ ]
436
+ mock_database_service.get_consumptions.return_value = mock_consumptions
437
+
438
+ # Mock contract data
439
+ mock_contracts = [
440
+ type(
441
+ "MockContract",
442
+ (),
443
+ {
444
+ "power_p1": 4.0,
445
+ "power_p2": 4.0,
446
+ "date_start": datetime(2024, 6, 17, 0, 0),
447
+ "date_end": datetime(2024, 6, 18, 0, 0),
448
+ },
449
+ )()
450
+ ]
451
+ mock_database_service.get_contracts.return_value = mock_contracts
452
+
453
+ # Mock the save_billing method to return a success response
454
+ mock_database_service.save_billing.return_value = type("MockBilling", (), {})()
455
+
456
+ # Mock billing data after calculation
457
+ mock_billing_results = [
458
+ type(
459
+ "MockBilling",
460
+ (),
461
+ {
462
+ "datetime": datetime(2024, 6, 17, 10, 0),
463
+ "total_eur": 0.05,
464
+ "energy_term": 0.03,
465
+ "power_term": 0.015,
466
+ "others_term": 0.005,
467
+ "surplus_term": 0.0,
468
+ },
469
+ )(),
470
+ type(
471
+ "MockBilling",
472
+ (),
473
+ {
474
+ "datetime": datetime(2024, 6, 17, 11, 0),
475
+ "total_eur": 0.06,
476
+ "energy_term": 0.036,
477
+ "power_term": 0.015,
478
+ "others_term": 0.005,
479
+ "surplus_term": 0.0,
480
+ },
481
+ )(),
482
+ ]
483
+
484
+ # Configure get_billing to return empty first, then billing results after update_missing_costs
485
+ mock_database_service.get_billing.side_effect = [
486
+ [],
487
+ mock_billing_results,
488
+ mock_billing_results,
489
+ ]
490
+
491
+ # Mock tariff calculation - need one call per hour in the data
492
+ mock_get_pvpc_tariff.return_value = (
493
+ "p1" # Use return_value instead of side_effect
494
+ )
495
+
496
+ # Execute cost calculation
497
+ result = await billing_service.get_cost(
498
+ cups=cups,
499
+ pricing_rules=sample_pricing_rules_custom,
500
+ start_date=start_date,
501
+ end_date=end_date,
502
+ )
503
+
504
+ # Validate result aggregation from mocked billing data
505
+ assert isinstance(result, PricingAggregated)
506
+ assert result.datetime == start_date
507
+ assert result.value_eur == 0.11 # 0.05 + 0.06
508
+ assert result.energy_term == 0.066 # 0.03 + 0.036
509
+ assert result.power_term == 0.03 # 0.015 + 0.015
510
+ assert result.others_term == 0.01 # 0.005 + 0.005
511
+ assert result.surplus_term == 0.0
512
+ assert result.delta_h == 2 # 2 billing records
513
+
514
+ # Verify database calls
515
+ mock_database_service.get_consumptions.assert_called_once_with(
516
+ cups, start_date, end_date
517
+ )
518
+ mock_database_service.get_contracts.assert_called_once_with(cups)
519
+
520
+ @pytest.mark.asyncio
521
+ async def test_get_cost_no_consumption_data(
522
+ self,
523
+ billing_service,
524
+ mock_redata_connector,
525
+ mock_database_service,
526
+ sample_pricing_rules_custom,
527
+ ):
528
+ """Test cost calculation with no consumption data."""
529
+ from datetime import datetime
530
+
531
+ from edata.models.pricing import PricingAggregated
532
+
533
+ mock_connector, mock_connector_class = mock_redata_connector
534
+ cups = "ES0123456789012345AB"
535
+ start_date = datetime(2024, 6, 17, 10, 0)
536
+ end_date = datetime(2024, 6, 17, 12, 0)
537
+
538
+ # Mock no existing billing data initially
539
+ mock_database_service.get_billing.return_value = []
540
+
541
+ # Mock the pricing config hash generation
542
+ mock_database_service.generate_pricing_config_hash.return_value = (
543
+ "test_hash_12345678"
544
+ )
545
+
546
+ # Mock no consumption data
547
+ mock_database_service.get_consumptions.return_value = []
548
+
549
+ # Execute cost calculation
550
+ result = await billing_service.get_cost(
551
+ cups=cups,
552
+ pricing_rules=sample_pricing_rules_custom,
553
+ start_date=start_date,
554
+ end_date=end_date,
555
+ )
556
+
557
+ # Should return default values when update_missing_costs fails
558
+ assert isinstance(result, PricingAggregated)
559
+ assert result.value_eur == 0.0
560
+ assert result.energy_term == 0.0
561
+ assert result.power_term == 0.0
562
+ assert result.others_term == 0.0
563
+ assert result.surplus_term == 0.0
564
+ assert result.delta_h == 2.0 # (end_date - start_date).total_seconds() / 3600
565
+
566
+ @pytest.mark.asyncio
567
+ async def test_get_cost_no_pricing_data(
568
+ self, billing_service, mock_redata_connector, mock_database_service
569
+ ):
570
+ """Test cost calculation with no pricing data available."""
571
+ from datetime import datetime
572
+
573
+ from edata.models.pricing import PricingAggregated, PricingRules
574
+
575
+ mock_connector, mock_connector_class = mock_redata_connector
576
+ cups = "ES0123456789012345AB"
577
+ start_date = datetime(2024, 6, 17, 10, 0)
578
+ end_date = datetime(2024, 6, 17, 12, 0)
579
+
580
+ # Mock no existing billing data initially
581
+ mock_database_service.get_billing.return_value = []
582
+
583
+ # Mock the pricing config hash generation
584
+ mock_database_service.generate_pricing_config_hash.return_value = (
585
+ "test_hash_12345678"
586
+ )
587
+
588
+ # Mock consumption data present
589
+ mock_consumptions = [
590
+ type(
591
+ "MockConsumption",
592
+ (),
593
+ {
594
+ "datetime": datetime(2024, 6, 17, 10, 0),
595
+ "value_kwh": 0.5,
596
+ "surplus_kwh": 0.0,
597
+ },
598
+ )()
599
+ ]
600
+ mock_database_service.get_consumptions.return_value = mock_consumptions
601
+
602
+ # Mock contract data present
603
+ mock_contracts = [
604
+ type(
605
+ "MockContract",
606
+ (),
607
+ {
608
+ "power_p1": 4.0,
609
+ "power_p2": 4.0,
610
+ "date_start": datetime(2024, 6, 17, 0, 0),
611
+ "date_end": datetime(2024, 6, 18, 0, 0),
612
+ },
613
+ )()
614
+ ]
615
+ mock_database_service.get_contracts.return_value = mock_contracts
616
+
617
+ # Mock no PVPC prices available
618
+ mock_database_service.get_pvpc_prices.return_value = []
619
+
620
+ # Create PVPC pricing rules
621
+ pvpc_pricing_rules = PricingRules(
622
+ p1_kw_year_eur=30.67,
623
+ p2_kw_year_eur=1.42,
624
+ p1_kwh_eur=None, # PVPC
625
+ p2_kwh_eur=None,
626
+ p3_kwh_eur=None,
627
+ surplus_p1_kwh_eur=0.05,
628
+ surplus_p2_kwh_eur=0.04,
629
+ surplus_p3_kwh_eur=0.03,
630
+ meter_month_eur=0.81,
631
+ market_kw_year_eur=3.11,
632
+ electricity_tax=1.05113,
633
+ iva_tax=1.21,
634
+ energy_formula="electricity_tax * iva_tax * kwh_eur * kwh",
635
+ power_formula="electricity_tax * iva_tax * (p1_kw * (p1_kw_year_eur + market_kw_year_eur) + p2_kw * p2_kw_year_eur) / 365 / 24",
636
+ others_formula="iva_tax * meter_month_eur / 30 / 24",
637
+ surplus_formula="electricity_tax * iva_tax * surplus_kwh * surplus_kwh_eur",
638
+ main_formula="energy_term + power_term + others_term",
639
+ )
640
+
641
+ # Execute cost calculation
642
+ result = await billing_service.get_cost(
643
+ cups=cups,
644
+ pricing_rules=pvpc_pricing_rules,
645
+ start_date=start_date,
646
+ end_date=end_date,
647
+ )
648
+
649
+ # Verify result when no pricing data available
650
+ assert isinstance(result, PricingAggregated)
651
+ assert result.datetime == start_date
652
+ assert result.value_eur == 0.0
653
+ assert result.energy_term == 0.0
654
+ assert result.power_term == 0.0
655
+ assert result.others_term == 0.0
656
+ assert result.surplus_term == 0.0
657
+
658
+ @pytest.mark.asyncio
659
+ async def test_jinja2_formula_evaluation(
660
+ self, billing_service, mock_redata_connector, mock_database_service
661
+ ):
662
+ """Test Jinja2 formula evaluation with predictable values."""
663
+ from datetime import datetime
664
+
665
+ from edata.models.pricing import PricingAggregated, PricingRules
666
+
667
+ mock_connector, mock_connector_class = mock_redata_connector
668
+ cups = "ES0123456789012345AB"
669
+ start_date = datetime(2024, 6, 17, 10, 0) # P1 period (Monday 10:00)
670
+ end_date = datetime(2024, 6, 17, 11, 0) # 1 hour
671
+
672
+ # Mock no existing billing data initially
673
+ mock_database_service.get_billing.return_value = []
674
+
675
+ # Mock the pricing config hash generation
676
+ mock_database_service.generate_pricing_config_hash.return_value = (
677
+ "test_hash_12345678"
678
+ )
679
+
680
+ # Mock predictable consumption data: 1 kWh consumed, 0.5 kWh surplus
681
+ mock_consumptions = [
682
+ type(
683
+ "MockConsumption",
684
+ (),
685
+ {
686
+ "datetime": datetime(2024, 6, 17, 10, 0),
687
+ "value_kwh": 1.0,
688
+ "surplus_kwh": 0.5,
689
+ },
690
+ )()
691
+ ]
692
+ mock_database_service.get_consumptions.return_value = mock_consumptions
693
+
694
+ # Mock predictable contract data: 5kW P1, 3kW P2
695
+ mock_contracts = [
696
+ type(
697
+ "MockContract",
698
+ (),
699
+ {
700
+ "power_p1": 5.0,
701
+ "power_p2": 3.0,
702
+ "date_start": datetime(2024, 6, 17, 0, 0),
703
+ "date_end": datetime(2024, 6, 18, 0, 0),
704
+ },
705
+ )()
706
+ ]
707
+ mock_database_service.get_contracts.return_value = mock_contracts
708
+
709
+ # Mock predictable PVPC prices: 0.10 €/kWh
710
+ mock_pvpc_prices = [
711
+ type(
712
+ "MockPVPCPrice",
713
+ (),
714
+ {
715
+ "datetime": datetime(2024, 6, 17, 10, 0),
716
+ "value_eur_kwh": 0.10,
717
+ "delta_h": 1.0,
718
+ },
719
+ )()
720
+ ]
721
+ mock_database_service.get_pvpc_prices.return_value = mock_pvpc_prices
722
+
723
+ # Mock the save_billing method to return a success response
724
+ mock_database_service.save_billing.return_value = type("MockBilling", (), {})()
725
+
726
+ # Mock billing result after calculation (with predictable values)
727
+ # Energy term: 1.05 * 1.21 * 0.10 * 1.0 = 0.12705
728
+ expected_energy_term = 1.05 * 1.21 * 0.10 * 1.0
729
+ # Power term: 1.05 * 1.21 * (5 * (40 + 4) + 3 * 20) / 365 / 24
730
+ expected_power_term = 1.05 * 1.21 * (5 * (40 + 4) + 3 * 20) / 365 / 24
731
+ # Others term: 1.21 * 3.0 / 30 / 24
732
+ expected_others_term = 1.21 * 3.0 / 30 / 24
733
+ # Surplus term: 1.05 * 1.21 * 0.5 * 0.06
734
+ expected_surplus_term = 1.05 * 1.21 * 0.5 * 0.06
735
+ # Total: energy + power + others - surplus
736
+ expected_total = (
737
+ expected_energy_term
738
+ + expected_power_term
739
+ + expected_others_term
740
+ - expected_surplus_term
741
+ )
742
+
743
+ mock_billing_result = [
744
+ type(
745
+ "MockBilling",
746
+ (),
747
+ {
748
+ "datetime": datetime(2024, 6, 17, 10, 0),
749
+ "total_eur": expected_total,
750
+ "energy_term": expected_energy_term,
751
+ "power_term": expected_power_term,
752
+ "others_term": expected_others_term,
753
+ "surplus_term": expected_surplus_term,
754
+ },
755
+ )()
756
+ ]
757
+
758
+ # Configure get_billing to return empty first, then billing results after update_missing_costs
759
+ mock_database_service.get_billing.side_effect = [
760
+ [],
761
+ mock_billing_result,
762
+ mock_billing_result,
763
+ ]
764
+
765
+ # Create PVPC pricing rules with simplified formulas for testing
766
+ test_pricing_rules = PricingRules(
767
+ p1_kw_year_eur=40.0, # €40/kW/year
768
+ p2_kw_year_eur=20.0, # €20/kW/year
769
+ p1_kwh_eur=None, # Use PVPC (0.10 €/kWh)
770
+ p2_kwh_eur=None,
771
+ p3_kwh_eur=None,
772
+ surplus_p1_kwh_eur=0.06, # €0.06/kWh surplus in P1
773
+ surplus_p2_kwh_eur=0.04, # €0.04/kWh surplus in P2
774
+ surplus_p3_kwh_eur=0.02, # €0.02/kWh surplus in P3
775
+ meter_month_eur=3.0, # €3/month meter
776
+ market_kw_year_eur=4.0, # €4/kW/year market
777
+ electricity_tax=1.05, # 5% electricity tax
778
+ iva_tax=1.21, # 21% IVA
779
+ # Simplified formulas for predictable calculation
780
+ energy_formula="electricity_tax * iva_tax * kwh_eur * kwh",
781
+ power_formula="electricity_tax * iva_tax * (p1_kw * (p1_kw_year_eur + market_kw_year_eur) + p2_kw * p2_kw_year_eur) / 365 / 24",
782
+ others_formula="iva_tax * meter_month_eur / 30 / 24",
783
+ surplus_formula="electricity_tax * iva_tax * surplus_kwh * surplus_kwh_eur",
784
+ main_formula="energy_term + power_term + others_term - surplus_term",
785
+ )
786
+
787
+ # Execute cost calculation
788
+ result = await billing_service.get_cost(
789
+ cups=cups,
790
+ pricing_rules=test_pricing_rules,
791
+ start_date=start_date,
792
+ end_date=end_date,
793
+ )
794
+
795
+ # Verify the result matches our mocked billing data
796
+ assert isinstance(result, PricingAggregated)
797
+ assert result.datetime == start_date
798
+ assert result.delta_h == 1 # 1 billing record
799
+
800
+ # Verify that the aggregated values match our expected calculations
801
+ assert round(result.energy_term, 4) == round(expected_energy_term, 4)
802
+ assert round(result.power_term, 4) == round(expected_power_term, 4)
803
+ assert round(result.others_term, 4) == round(expected_others_term, 4)
804
+ assert round(result.surplus_term, 4) == round(expected_surplus_term, 4)
805
+ assert round(result.value_eur, 4) == round(expected_total, 4)
806
+ assert round(result.value_eur, 5) == round(expected_total, 5)
807
+
808
+ @pytest.mark.asyncio
809
+ async def test_update_missing_costs(
810
+ self, billing_service, mock_redata_connector, mock_database_service
811
+ ):
812
+ """Test update_missing_costs method."""
813
+ from datetime import datetime
814
+
815
+ from edata.models.pricing import PricingRules
816
+
817
+ mock_connector, mock_connector_class = mock_redata_connector
818
+ cups = "ES0123456789012345AB"
819
+ start_date = datetime(2024, 6, 17, 10, 0)
820
+ end_date = datetime(2024, 6, 17, 12, 0)
821
+
822
+ # Mock consumption data
823
+ mock_consumptions = [
824
+ type(
825
+ "MockConsumption",
826
+ (),
827
+ {
828
+ "datetime": datetime(2024, 6, 17, 10, 0),
829
+ "value_kwh": 0.5,
830
+ "surplus_kwh": 0.0,
831
+ },
832
+ )(),
833
+ type(
834
+ "MockConsumption",
835
+ (),
836
+ {
837
+ "datetime": datetime(2024, 6, 17, 11, 0),
838
+ "value_kwh": 0.7,
839
+ "surplus_kwh": 0.1,
840
+ },
841
+ )(),
842
+ ]
843
+ mock_database_service.get_consumptions.return_value = mock_consumptions
844
+
845
+ # Mock contract data
846
+ mock_contracts = [
847
+ type(
848
+ "MockContract",
849
+ (),
850
+ {
851
+ "power_p1": 4.0,
852
+ "power_p2": 4.0,
853
+ "date_start": datetime(2024, 6, 17, 0, 0),
854
+ "date_end": datetime(2024, 6, 18, 0, 0),
855
+ },
856
+ )()
857
+ ]
858
+ mock_database_service.get_contracts.return_value = mock_contracts
859
+
860
+ # Mock no existing billing records
861
+ mock_database_service.get_billing.return_value = []
862
+
863
+ # Mock successful billing save
864
+ mock_billing_record = type("MockBilling", (), {"id": 1})()
865
+ mock_database_service.save_billing.return_value = mock_billing_record
866
+
867
+ # Mock hash generation
868
+ mock_database_service.generate_pricing_config_hash.return_value = (
869
+ "test_hash_123"
870
+ )
871
+
872
+ # Create custom pricing rules (no PVPC)
873
+ custom_pricing_rules = PricingRules(
874
+ p1_kw_year_eur=30.0,
875
+ p2_kw_year_eur=20.0,
876
+ p1_kwh_eur=0.15, # Custom prices - no PVPC
877
+ p2_kwh_eur=0.12,
878
+ p3_kwh_eur=0.10,
879
+ surplus_p1_kwh_eur=0.06,
880
+ surplus_p2_kwh_eur=0.04,
881
+ surplus_p3_kwh_eur=0.02,
882
+ meter_month_eur=3.0,
883
+ market_kw_year_eur=4.0,
884
+ electricity_tax=1.05,
885
+ iva_tax=1.21,
886
+ energy_formula="electricity_tax * iva_tax * kwh_eur * kwh",
887
+ power_formula="electricity_tax * iva_tax * (p1_kw * (p1_kw_year_eur + market_kw_year_eur) + p2_kw * p2_kw_year_eur) / 365 / 24",
888
+ others_formula="iva_tax * meter_month_eur / 30 / 24",
889
+ surplus_formula="electricity_tax * iva_tax * surplus_kwh * surplus_kwh_eur",
890
+ main_formula="energy_term + power_term + others_term - surplus_term",
891
+ )
892
+
893
+ # Execute update_missing_costs
894
+ result = await billing_service.update_missing_costs(
895
+ cups=cups,
896
+ pricing_rules=custom_pricing_rules,
897
+ start_date=start_date,
898
+ end_date=end_date,
899
+ )
900
+
901
+ # Verify successful result
902
+ assert result["success"] is True
903
+ assert result["cups"] == cups
904
+ assert result["pricing_config_hash"] == "test_hash_123"
905
+
906
+ # Verify statistics
907
+ stats = result["stats"]
908
+ assert stats["total_consumptions"] == 2
909
+ assert stats["processed"] > 0 # Should have processed some records
910
+
911
+ # Verify database methods were called
912
+ mock_database_service.get_consumptions.assert_called_once_with(
913
+ cups, start_date, end_date
914
+ )
915
+ mock_database_service.get_contracts.assert_called_once_with(cups)
916
+ mock_database_service.get_billing.assert_called_once()
917
+ mock_database_service.generate_pricing_config_hash.assert_called_once()
918
+
919
+ # Verify save_billing was called (at least once)
920
+ assert mock_database_service.save_billing.call_count > 0
921
+
922
+ @pytest.mark.asyncio
923
+ async def test_get_daily_costs_with_existing_data(
924
+ self, billing_service, mock_database_service, sample_pricing_rules_custom
925
+ ):
926
+ """Test get_daily_costs with existing billing data."""
927
+ from edata.models.database import BillingModel
928
+
929
+ # Create mock billing records for 2 days
930
+ base_date = datetime(2024, 1, 1, 0, 0, 0)
931
+ mock_billing_records = []
932
+
933
+ # Create 48 hours of billing data (2 days)
934
+ for hour in range(48):
935
+ record = BillingModel(
936
+ datetime=base_date + timedelta(hours=hour),
937
+ cups="ES0012345678901234567890AB",
938
+ pricing_config_hash="test_hash",
939
+ total_eur=1.5 + (hour * 0.1), # Varying costs
940
+ energy_term=1.0 + (hour * 0.05),
941
+ power_term=0.3,
942
+ others_term=0.1,
943
+ surplus_term=0.1 + (hour * 0.05),
944
+ )
945
+ mock_billing_records.append(record)
946
+
947
+ # Setup mocks
948
+ mock_database_service.generate_pricing_config_hash.return_value = "test_hash"
949
+ mock_database_service.get_billing.return_value = mock_billing_records
950
+
951
+ # Test parameters
952
+ cups = "ES0012345678901234567890AB"
953
+ start_date = datetime(2024, 1, 1)
954
+ end_date = datetime(2024, 1, 2, 23, 59, 59)
955
+
956
+ # Call method
957
+ result = await billing_service.get_daily_costs(
958
+ cups, sample_pricing_rules_custom, start_date, end_date
959
+ )
960
+
961
+ # Assertions
962
+ assert len(result) == 2 # Two days
963
+ from edata.models.pricing import PricingAggregated
964
+
965
+ assert all(isinstance(item, PricingAggregated) for item in result)
966
+
967
+ # Check first day
968
+ first_day = result[0]
969
+ assert first_day.datetime.date() == datetime(2024, 1, 1).date()
970
+ assert first_day.delta_h == 24 # 24 hours
971
+ assert first_day.value_eur > 0
972
+
973
+ # Check second day
974
+ second_day = result[1]
975
+ assert second_day.datetime.date() == datetime(2024, 1, 2).date()
976
+ assert second_day.delta_h == 24 # 24 hours
977
+ assert (
978
+ second_day.value_eur > first_day.value_eur
979
+ ) # Should be higher due to increasing pattern
980
+
981
+ @pytest.mark.asyncio
982
+ async def test_get_daily_costs_without_existing_data(
983
+ self, billing_service, mock_database_service, sample_pricing_rules_custom
984
+ ):
985
+ """Test get_daily_costs when no billing data exists."""
986
+ # Setup mocks - no existing data
987
+ mock_database_service.generate_pricing_config_hash.return_value = "test_hash"
988
+ mock_database_service.get_billing.side_effect = [
989
+ [],
990
+ [],
991
+ ] # First call empty, second still empty
992
+
993
+ # Mock update_missing_costs to fail
994
+ with patch.object(billing_service, "update_missing_costs") as mock_update:
995
+ mock_update.return_value = {"success": False, "error": "Test error"}
996
+
997
+ # Test parameters
998
+ cups = "ES0012345678901234567890AB"
999
+ start_date = datetime(2024, 1, 1)
1000
+ end_date = datetime(2024, 1, 1, 23, 59, 59)
1001
+
1002
+ # Call method
1003
+ result = await billing_service.get_daily_costs(
1004
+ cups, sample_pricing_rules_custom, start_date, end_date
1005
+ )
1006
+
1007
+ # Assertions
1008
+ assert result == [] # Should return empty list when update fails
1009
+ mock_update.assert_called_once()
1010
+
1011
+ @pytest.mark.asyncio
1012
+ async def test_get_monthly_costs_with_existing_data(
1013
+ self, billing_service, mock_database_service, sample_pricing_rules_custom
1014
+ ):
1015
+ """Test get_monthly_costs with existing billing data."""
1016
+ from edata.models.database import BillingModel
1017
+
1018
+ # Create mock billing records for 2 days in same month
1019
+ base_date = datetime(2024, 1, 1, 0, 0, 0)
1020
+ mock_billing_records = []
1021
+
1022
+ # Create 48 hours of billing data (2 days)
1023
+ for hour in range(48):
1024
+ record = BillingModel(
1025
+ datetime=base_date + timedelta(hours=hour),
1026
+ cups="ES0012345678901234567890AB",
1027
+ pricing_config_hash="test_hash",
1028
+ total_eur=1.5 + (hour * 0.1), # Varying costs
1029
+ energy_term=1.0 + (hour * 0.05),
1030
+ power_term=0.3,
1031
+ others_term=0.1,
1032
+ surplus_term=0.1 + (hour * 0.05),
1033
+ )
1034
+ mock_billing_records.append(record)
1035
+
1036
+ # Setup mocks
1037
+ mock_database_service.generate_pricing_config_hash.return_value = "test_hash"
1038
+ mock_database_service.get_billing.return_value = mock_billing_records
1039
+
1040
+ # Test parameters
1041
+ cups = "ES0012345678901234567890AB"
1042
+ start_date = datetime(2024, 1, 1)
1043
+ end_date = datetime(2024, 1, 31, 23, 59, 59)
1044
+
1045
+ # Call method
1046
+ result = await billing_service.get_monthly_costs(
1047
+ cups, sample_pricing_rules_custom, start_date, end_date
1048
+ )
1049
+
1050
+ # Assertions
1051
+ assert len(result) == 1 # One month
1052
+ from edata.models.pricing import PricingAggregated
1053
+
1054
+ assert all(isinstance(item, PricingAggregated) for item in result)
1055
+
1056
+ # Check month
1057
+ month_data = result[0]
1058
+ assert month_data.datetime.date() == datetime(2024, 1, 1).date()
1059
+ assert month_data.delta_h == 48 # 48 hours from mock data
1060
+ assert month_data.value_eur > 0
1061
+
1062
+ @pytest.mark.asyncio
1063
+ async def test_get_monthly_costs_multiple_months(
1064
+ self, billing_service, mock_database_service, sample_pricing_rules_custom
1065
+ ):
1066
+ """Test get_monthly_costs with data spanning multiple months."""
1067
+ from edata.models.database import BillingModel
1068
+
1069
+ # Create billing records spanning two months
1070
+ records = []
1071
+
1072
+ # January data (24 hours)
1073
+ for hour in range(24):
1074
+ record = BillingModel(
1075
+ datetime=datetime(2024, 1, 15) + timedelta(hours=hour),
1076
+ cups="ES0012345678901234567890AB",
1077
+ pricing_config_hash="test_hash",
1078
+ total_eur=1.0,
1079
+ energy_term=0.7,
1080
+ power_term=0.2,
1081
+ others_term=0.1,
1082
+ surplus_term=0.0,
1083
+ )
1084
+ records.append(record)
1085
+
1086
+ # February data (24 hours)
1087
+ for hour in range(24):
1088
+ record = BillingModel(
1089
+ datetime=datetime(2024, 2, 15) + timedelta(hours=hour),
1090
+ cups="ES0012345678901234567890AB",
1091
+ pricing_config_hash="test_hash",
1092
+ total_eur=1.2,
1093
+ energy_term=0.8,
1094
+ power_term=0.3,
1095
+ others_term=0.1,
1096
+ surplus_term=0.0,
1097
+ )
1098
+ records.append(record)
1099
+
1100
+ # Setup mocks
1101
+ mock_database_service.generate_pricing_config_hash.return_value = "test_hash"
1102
+ mock_database_service.get_billing.return_value = records
1103
+
1104
+ # Test parameters
1105
+ cups = "ES0012345678901234567890AB"
1106
+ start_date = datetime(2024, 1, 1)
1107
+ end_date = datetime(2024, 2, 28, 23, 59, 59)
1108
+
1109
+ # Call method
1110
+ result = await billing_service.get_monthly_costs(
1111
+ cups, sample_pricing_rules_custom, start_date, end_date
1112
+ )
1113
+
1114
+ # Assertions
1115
+ assert len(result) == 2 # Two months
1116
+
1117
+ # Check January
1118
+ jan_data = result[0]
1119
+ assert jan_data.datetime.date() == datetime(2024, 1, 1).date()
1120
+ assert jan_data.delta_h == 24
1121
+ assert jan_data.value_eur == 24.0 # 24 hours * 1.0 EUR
1122
+
1123
+ # Check February
1124
+ feb_data = result[1]
1125
+ assert feb_data.datetime.date() == datetime(2024, 2, 1).date()
1126
+ assert feb_data.delta_h == 24
1127
+ assert feb_data.value_eur == 28.8 # 24 hours * 1.2 EUR
1128
+
1129
+ @pytest.mark.asyncio
1130
+ async def test_get_daily_costs_error_handling(
1131
+ self, billing_service, mock_database_service, sample_pricing_rules_custom
1132
+ ):
1133
+ """Test error handling in get_daily_costs."""
1134
+ # Setup mocks to raise exception
1135
+ mock_database_service.generate_pricing_config_hash.side_effect = Exception(
1136
+ "Database error"
1137
+ )
1138
+
1139
+ # Test parameters
1140
+ cups = "ES0012345678901234567890AB"
1141
+ start_date = datetime(2024, 1, 1)
1142
+ end_date = datetime(2024, 1, 1, 23, 59, 59)
1143
+
1144
+ # Call method and expect exception
1145
+ with pytest.raises(Exception, match="Database error"):
1146
+ await billing_service.get_daily_costs(
1147
+ cups, sample_pricing_rules_custom, start_date, end_date
1148
+ )
1149
+
1150
+ @pytest.mark.asyncio
1151
+ async def test_get_monthly_costs_error_handling(
1152
+ self, billing_service, mock_database_service, sample_pricing_rules_custom
1153
+ ):
1154
+ """Test error handling in get_monthly_costs."""
1155
+ # Setup mocks to raise exception
1156
+ mock_database_service.generate_pricing_config_hash.side_effect = Exception(
1157
+ "Database error"
1158
+ )
1159
+
1160
+ # Test parameters
1161
+ cups = "ES0012345678901234567890AB"
1162
+ start_date = datetime(2024, 1, 1)
1163
+ end_date = datetime(2024, 1, 31, 23, 59, 59)
1164
+
1165
+ # Call method and expect exception
1166
+ with pytest.raises(Exception, match="Database error"):
1167
+ await billing_service.get_monthly_costs(
1168
+ cups, sample_pricing_rules_custom, start_date, end_date
1169
+ )