arthexis 0.1.26__py3-none-any.whl → 0.1.28__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/tests.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import sys
|
|
3
3
|
import time
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections import deque
|
|
4
6
|
from importlib import util as importlib_util
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from types import ModuleType
|
|
@@ -63,13 +65,17 @@ from .models import (
|
|
|
63
65
|
Transaction,
|
|
64
66
|
Charger,
|
|
65
67
|
ChargerConfiguration,
|
|
68
|
+
ConfigurationKey,
|
|
66
69
|
Simulator,
|
|
67
70
|
MeterReading,
|
|
71
|
+
MeterValue,
|
|
68
72
|
Location,
|
|
69
73
|
DataTransferMessage,
|
|
70
74
|
CPReservation,
|
|
75
|
+
CPFirmware,
|
|
76
|
+
CPFirmwareDeployment,
|
|
71
77
|
)
|
|
72
|
-
from .admin import ChargerConfigurationAdmin
|
|
78
|
+
from .admin import ChargerConfigurationAdmin, ConfigurationKeyAdmin, ConfigurationKeyInline
|
|
73
79
|
from .consumers import CSMSConsumer
|
|
74
80
|
from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
|
|
75
81
|
from .status_display import STATUS_BADGE_MAP
|
|
@@ -90,7 +96,8 @@ from .tasks import (
|
|
|
90
96
|
check_charge_point_configuration,
|
|
91
97
|
schedule_daily_charge_point_configuration_checks,
|
|
92
98
|
)
|
|
93
|
-
from django.db import close_old_connections
|
|
99
|
+
from django.db import close_old_connections, connection
|
|
100
|
+
from django.test.utils import CaptureQueriesContext
|
|
94
101
|
from django.db.utils import OperationalError
|
|
95
102
|
from urllib.parse import unquote, urlparse
|
|
96
103
|
|
|
@@ -394,6 +401,45 @@ class CPReservationTests(TransactionTestCase):
|
|
|
394
401
|
self.assertIsNotNone(metadata)
|
|
395
402
|
self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
|
|
396
403
|
|
|
404
|
+
def test_send_cancel_request_dispatches_frame(self):
|
|
405
|
+
start = timezone.now() + timedelta(hours=1)
|
|
406
|
+
reservation = CPReservation.objects.create(
|
|
407
|
+
location=self.location,
|
|
408
|
+
start_time=start,
|
|
409
|
+
duration_minutes=30,
|
|
410
|
+
id_tag="TAG010",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
class DummyConnection:
|
|
414
|
+
def __init__(self):
|
|
415
|
+
self.sent: list[str] = []
|
|
416
|
+
|
|
417
|
+
async def send(self, message):
|
|
418
|
+
self.sent.append(message)
|
|
419
|
+
|
|
420
|
+
ws = DummyConnection()
|
|
421
|
+
store.set_connection(
|
|
422
|
+
reservation.connector.charger_id,
|
|
423
|
+
reservation.connector.connector_id,
|
|
424
|
+
ws,
|
|
425
|
+
)
|
|
426
|
+
self.addCleanup(
|
|
427
|
+
store.pop_connection,
|
|
428
|
+
reservation.connector.charger_id,
|
|
429
|
+
reservation.connector.connector_id,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
message_id = reservation.send_cancel_request()
|
|
433
|
+
self.assertTrue(ws.sent)
|
|
434
|
+
frame = json.loads(ws.sent[0])
|
|
435
|
+
self.assertEqual(frame[0], 2)
|
|
436
|
+
self.assertEqual(frame[2], "CancelReservation")
|
|
437
|
+
self.assertEqual(frame[3]["reservationId"], reservation.pk)
|
|
438
|
+
metadata = store.pending_calls.get(message_id)
|
|
439
|
+
self.assertIsNotNone(metadata)
|
|
440
|
+
self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
|
|
441
|
+
self.assertEqual(metadata.get("action"), "CancelReservation")
|
|
442
|
+
|
|
397
443
|
def test_call_result_marks_reservation_confirmed(self):
|
|
398
444
|
start = timezone.now() + timedelta(hours=1)
|
|
399
445
|
reservation = CPReservation.objects.create(
|
|
@@ -482,6 +528,95 @@ class CPReservationTests(TransactionTestCase):
|
|
|
482
528
|
self.assertIsNone(reservation.evcs_confirmed_at)
|
|
483
529
|
self.assertIn("Rejected", reservation.evcs_error or "")
|
|
484
530
|
|
|
531
|
+
def test_cancel_reservation_result_updates_status(self):
|
|
532
|
+
start = timezone.now() + timedelta(hours=1)
|
|
533
|
+
reservation = CPReservation.objects.create(
|
|
534
|
+
location=self.location,
|
|
535
|
+
start_time=start,
|
|
536
|
+
duration_minutes=45,
|
|
537
|
+
id_tag="TAG008",
|
|
538
|
+
)
|
|
539
|
+
log_key = store.identity_key(
|
|
540
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
541
|
+
)
|
|
542
|
+
message_id = "cancel-success"
|
|
543
|
+
store.register_pending_call(
|
|
544
|
+
message_id,
|
|
545
|
+
{
|
|
546
|
+
"action": "CancelReservation",
|
|
547
|
+
"charger_id": reservation.connector.charger_id,
|
|
548
|
+
"connector_id": reservation.connector.connector_id,
|
|
549
|
+
"log_key": log_key,
|
|
550
|
+
"reservation_pk": reservation.pk,
|
|
551
|
+
},
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
consumer = CSMSConsumer()
|
|
555
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
556
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
557
|
+
consumer.store_key = log_key
|
|
558
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
559
|
+
consumer.charger = reservation.connector
|
|
560
|
+
consumer.aggregate_charger = self.aggregate
|
|
561
|
+
consumer._consumption_task = None
|
|
562
|
+
consumer._consumption_message_uuid = None
|
|
563
|
+
consumer.send = AsyncMock()
|
|
564
|
+
|
|
565
|
+
async_to_sync(consumer._handle_call_result)(
|
|
566
|
+
message_id, {"status": "Accepted"}
|
|
567
|
+
)
|
|
568
|
+
reservation.refresh_from_db()
|
|
569
|
+
self.assertEqual(reservation.evcs_status, "Accepted")
|
|
570
|
+
self.assertFalse(reservation.evcs_confirmed)
|
|
571
|
+
self.assertIsNone(reservation.evcs_confirmed_at)
|
|
572
|
+
self.assertEqual(reservation.evcs_error, "")
|
|
573
|
+
|
|
574
|
+
def test_cancel_reservation_error_updates_status(self):
|
|
575
|
+
start = timezone.now() + timedelta(hours=1)
|
|
576
|
+
reservation = CPReservation.objects.create(
|
|
577
|
+
location=self.location,
|
|
578
|
+
start_time=start,
|
|
579
|
+
duration_minutes=45,
|
|
580
|
+
id_tag="TAG009",
|
|
581
|
+
)
|
|
582
|
+
log_key = store.identity_key(
|
|
583
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
584
|
+
)
|
|
585
|
+
message_id = "cancel-error"
|
|
586
|
+
store.register_pending_call(
|
|
587
|
+
message_id,
|
|
588
|
+
{
|
|
589
|
+
"action": "CancelReservation",
|
|
590
|
+
"charger_id": reservation.connector.charger_id,
|
|
591
|
+
"connector_id": reservation.connector.connector_id,
|
|
592
|
+
"log_key": log_key,
|
|
593
|
+
"reservation_pk": reservation.pk,
|
|
594
|
+
},
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
consumer = CSMSConsumer()
|
|
598
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
599
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
600
|
+
consumer.store_key = log_key
|
|
601
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
602
|
+
consumer.charger = reservation.connector
|
|
603
|
+
consumer.aggregate_charger = self.aggregate
|
|
604
|
+
consumer._consumption_task = None
|
|
605
|
+
consumer._consumption_message_uuid = None
|
|
606
|
+
consumer.send = AsyncMock()
|
|
607
|
+
|
|
608
|
+
async_to_sync(consumer._handle_call_error)(
|
|
609
|
+
message_id,
|
|
610
|
+
"Rejected",
|
|
611
|
+
"Connector busy",
|
|
612
|
+
{"reason": "occupied"},
|
|
613
|
+
)
|
|
614
|
+
reservation.refresh_from_db()
|
|
615
|
+
self.assertEqual(reservation.evcs_status, "")
|
|
616
|
+
self.assertFalse(reservation.evcs_confirmed)
|
|
617
|
+
self.assertIsNone(reservation.evcs_confirmed_at)
|
|
618
|
+
self.assertIn("Rejected", reservation.evcs_error or "")
|
|
619
|
+
|
|
485
620
|
|
|
486
621
|
class ChargerUrlFallbackTests(TestCase):
|
|
487
622
|
@override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
|
|
@@ -532,6 +667,33 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
532
667
|
await asyncio.sleep(delay)
|
|
533
668
|
raise
|
|
534
669
|
|
|
670
|
+
def _create_firmware_deployment(self, charger_id: str) -> int:
|
|
671
|
+
try:
|
|
672
|
+
charger = Charger.objects.get(charger_id=charger_id, connector_id=None)
|
|
673
|
+
except Charger.DoesNotExist:
|
|
674
|
+
charger = Charger.objects.create(charger_id=charger_id, connector_id=None)
|
|
675
|
+
firmware = CPFirmware.objects.create(
|
|
676
|
+
name=f"{charger_id} firmware",
|
|
677
|
+
filename=f"{charger_id}.bin",
|
|
678
|
+
payload_binary=b"firmware",
|
|
679
|
+
content_type="application/octet-stream",
|
|
680
|
+
source=CPFirmware.Source.DOWNLOAD,
|
|
681
|
+
is_user_data=True,
|
|
682
|
+
)
|
|
683
|
+
deployment = CPFirmwareDeployment.objects.create(
|
|
684
|
+
firmware=firmware,
|
|
685
|
+
charger=charger,
|
|
686
|
+
node=charger.node_origin,
|
|
687
|
+
ocpp_message_id=f"deploy-{charger_id}",
|
|
688
|
+
status="Pending",
|
|
689
|
+
status_info="",
|
|
690
|
+
status_timestamp=timezone.now(),
|
|
691
|
+
retrieve_date=timezone.now(),
|
|
692
|
+
request_payload={},
|
|
693
|
+
is_user_data=True,
|
|
694
|
+
)
|
|
695
|
+
return deployment.pk
|
|
696
|
+
|
|
535
697
|
async def _send_status_notification(self, serial: str, payload: dict):
|
|
536
698
|
communicator = WebsocketCommunicator(application, f"/{serial}/")
|
|
537
699
|
connected, _ = await communicator.connect()
|
|
@@ -712,7 +874,9 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
712
874
|
store.logs.setdefault("charger", {})
|
|
713
875
|
store.logs["charger"].clear()
|
|
714
876
|
for key, entries in original_logs.items():
|
|
715
|
-
store.logs["charger"][key] =
|
|
877
|
+
store.logs["charger"][key] = deque(
|
|
878
|
+
entries, maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES
|
|
879
|
+
)
|
|
716
880
|
store.log_names.setdefault("charger", {})
|
|
717
881
|
store.log_names["charger"].clear()
|
|
718
882
|
store.log_names["charger"].update(original_log_names)
|
|
@@ -977,6 +1141,151 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
977
1141
|
self.assertEqual(charger.availability_requested_state, "Inoperative")
|
|
978
1142
|
await communicator.disconnect()
|
|
979
1143
|
|
|
1144
|
+
async def test_clear_cache_result_updates_local_auth_version(self):
|
|
1145
|
+
store.pending_calls.clear()
|
|
1146
|
+
log_key = store.identity_key("CLEARCACHE", None)
|
|
1147
|
+
store.clear_log(log_key, log_type="charger")
|
|
1148
|
+
communicator = WebsocketCommunicator(application, "/CLEARCACHE/")
|
|
1149
|
+
connected, _ = await communicator.connect()
|
|
1150
|
+
self.assertTrue(connected)
|
|
1151
|
+
|
|
1152
|
+
await communicator.send_json_to(
|
|
1153
|
+
[
|
|
1154
|
+
2,
|
|
1155
|
+
"boot",
|
|
1156
|
+
"BootNotification",
|
|
1157
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
1158
|
+
]
|
|
1159
|
+
)
|
|
1160
|
+
await communicator.receive_json_from()
|
|
1161
|
+
|
|
1162
|
+
message_id = "cc-accepted"
|
|
1163
|
+
requested_at = timezone.now()
|
|
1164
|
+
store.register_pending_call(
|
|
1165
|
+
message_id,
|
|
1166
|
+
{
|
|
1167
|
+
"action": "ClearCache",
|
|
1168
|
+
"charger_id": "CLEARCACHE",
|
|
1169
|
+
"connector_id": None,
|
|
1170
|
+
"log_key": log_key,
|
|
1171
|
+
"requested_at": requested_at,
|
|
1172
|
+
},
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
mock_update = AsyncMock()
|
|
1176
|
+
with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
|
|
1177
|
+
await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
|
|
1178
|
+
await asyncio.sleep(0.05)
|
|
1179
|
+
|
|
1180
|
+
mock_update.assert_awaited_with(0)
|
|
1181
|
+
result = store.wait_for_pending_call(message_id, timeout=0.1)
|
|
1182
|
+
self.assertIsNotNone(result)
|
|
1183
|
+
payload = result.get("payload") or {}
|
|
1184
|
+
self.assertEqual(payload.get("status"), "Accepted")
|
|
1185
|
+
log_entries = store.logs["charger"].get(log_key, [])
|
|
1186
|
+
self.assertTrue(any("ClearCache result" in entry for entry in log_entries))
|
|
1187
|
+
|
|
1188
|
+
store.clear_log(log_key, log_type="charger")
|
|
1189
|
+
await communicator.disconnect()
|
|
1190
|
+
|
|
1191
|
+
async def test_clear_cache_rejection_updates_timestamp(self):
|
|
1192
|
+
store.pending_calls.clear()
|
|
1193
|
+
log_key = store.identity_key("CLEARCACHE-REJ", None)
|
|
1194
|
+
store.clear_log(log_key, log_type="charger")
|
|
1195
|
+
communicator = WebsocketCommunicator(application, "/CLEARCACHE-REJ/")
|
|
1196
|
+
connected, _ = await communicator.connect()
|
|
1197
|
+
self.assertTrue(connected)
|
|
1198
|
+
|
|
1199
|
+
await communicator.send_json_to(
|
|
1200
|
+
[
|
|
1201
|
+
2,
|
|
1202
|
+
"boot",
|
|
1203
|
+
"BootNotification",
|
|
1204
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
1205
|
+
]
|
|
1206
|
+
)
|
|
1207
|
+
await communicator.receive_json_from()
|
|
1208
|
+
|
|
1209
|
+
message_id = "cc-rejected"
|
|
1210
|
+
store.register_pending_call(
|
|
1211
|
+
message_id,
|
|
1212
|
+
{
|
|
1213
|
+
"action": "ClearCache",
|
|
1214
|
+
"charger_id": "CLEARCACHE-REJ",
|
|
1215
|
+
"connector_id": None,
|
|
1216
|
+
"log_key": log_key,
|
|
1217
|
+
},
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
mock_update = AsyncMock()
|
|
1221
|
+
with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
|
|
1222
|
+
await communicator.send_json_to([3, message_id, {"status": "Rejected"}])
|
|
1223
|
+
await asyncio.sleep(0.05)
|
|
1224
|
+
|
|
1225
|
+
mock_update.assert_awaited_with(None)
|
|
1226
|
+
result = store.wait_for_pending_call(message_id, timeout=0.1)
|
|
1227
|
+
self.assertIsNotNone(result)
|
|
1228
|
+
payload = result.get("payload") or {}
|
|
1229
|
+
self.assertEqual(payload.get("status"), "Rejected")
|
|
1230
|
+
log_entries = store.logs["charger"].get(log_key, [])
|
|
1231
|
+
self.assertTrue(any("ClearCache result" in entry for entry in log_entries))
|
|
1232
|
+
|
|
1233
|
+
store.clear_log(log_key, log_type="charger")
|
|
1234
|
+
await communicator.disconnect()
|
|
1235
|
+
|
|
1236
|
+
async def test_clear_cache_error_records_failure(self):
|
|
1237
|
+
store.pending_calls.clear()
|
|
1238
|
+
log_key = store.identity_key("CLEARCACHE-ERR", None)
|
|
1239
|
+
store.clear_log(log_key, log_type="charger")
|
|
1240
|
+
communicator = WebsocketCommunicator(application, "/CLEARCACHE-ERR/")
|
|
1241
|
+
connected, _ = await communicator.connect()
|
|
1242
|
+
self.assertTrue(connected)
|
|
1243
|
+
|
|
1244
|
+
await communicator.send_json_to(
|
|
1245
|
+
[
|
|
1246
|
+
2,
|
|
1247
|
+
"boot",
|
|
1248
|
+
"BootNotification",
|
|
1249
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
1250
|
+
]
|
|
1251
|
+
)
|
|
1252
|
+
await communicator.receive_json_from()
|
|
1253
|
+
|
|
1254
|
+
message_id = "cc-error"
|
|
1255
|
+
store.register_pending_call(
|
|
1256
|
+
message_id,
|
|
1257
|
+
{
|
|
1258
|
+
"action": "ClearCache",
|
|
1259
|
+
"charger_id": "CLEARCACHE-ERR",
|
|
1260
|
+
"connector_id": None,
|
|
1261
|
+
"log_key": log_key,
|
|
1262
|
+
},
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
mock_update = AsyncMock()
|
|
1266
|
+
with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
|
|
1267
|
+
await communicator.send_json_to(
|
|
1268
|
+
[
|
|
1269
|
+
4,
|
|
1270
|
+
message_id,
|
|
1271
|
+
"InternalError",
|
|
1272
|
+
"Failed",
|
|
1273
|
+
{"detail": "boom"},
|
|
1274
|
+
]
|
|
1275
|
+
)
|
|
1276
|
+
await asyncio.sleep(0.05)
|
|
1277
|
+
|
|
1278
|
+
mock_update.assert_awaited_with(None)
|
|
1279
|
+
result = store.wait_for_pending_call(message_id, timeout=0.1)
|
|
1280
|
+
self.assertIsNotNone(result)
|
|
1281
|
+
self.assertFalse(result.get("success"))
|
|
1282
|
+
self.assertEqual(result.get("error_code"), "InternalError")
|
|
1283
|
+
log_entries = store.logs["charger"].get(log_key, [])
|
|
1284
|
+
self.assertTrue(any("ClearCache error" in entry for entry in log_entries))
|
|
1285
|
+
|
|
1286
|
+
store.clear_log(log_key, log_type="charger")
|
|
1287
|
+
await communicator.disconnect()
|
|
1288
|
+
|
|
980
1289
|
async def test_get_configuration_result_logged(self):
|
|
981
1290
|
store.pending_calls.clear()
|
|
982
1291
|
pending_key = store.pending_key("CFGRES")
|
|
@@ -1040,6 +1349,30 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1040
1349
|
}
|
|
1041
1350
|
],
|
|
1042
1351
|
)
|
|
1352
|
+
key_rows = await database_sync_to_async(
|
|
1353
|
+
lambda: [
|
|
1354
|
+
{
|
|
1355
|
+
"key": item.key,
|
|
1356
|
+
"value": item.value,
|
|
1357
|
+
"readonly": item.readonly,
|
|
1358
|
+
"has_value": item.has_value,
|
|
1359
|
+
}
|
|
1360
|
+
for item in ConfigurationKey.objects.filter(
|
|
1361
|
+
configuration=configuration
|
|
1362
|
+
).order_by("position", "id")
|
|
1363
|
+
]
|
|
1364
|
+
)()
|
|
1365
|
+
self.assertEqual(
|
|
1366
|
+
key_rows,
|
|
1367
|
+
[
|
|
1368
|
+
{
|
|
1369
|
+
"key": "AllowOfflineTxForUnknownId",
|
|
1370
|
+
"value": "false",
|
|
1371
|
+
"readonly": True,
|
|
1372
|
+
"has_value": True,
|
|
1373
|
+
}
|
|
1374
|
+
],
|
|
1375
|
+
)
|
|
1043
1376
|
self.assertEqual(configuration.unknown_keys, [])
|
|
1044
1377
|
config_ids = await database_sync_to_async(
|
|
1045
1378
|
lambda: set(
|
|
@@ -1262,91 +1595,179 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1262
1595
|
await communicator.disconnect()
|
|
1263
1596
|
|
|
1264
1597
|
async def test_firmware_status_notification_updates_database_and_views(self):
|
|
1598
|
+
store.ip_connections.clear()
|
|
1599
|
+
limit = store.MAX_CONNECTIONS_PER_IP
|
|
1600
|
+
store.MAX_CONNECTIONS_PER_IP = 10
|
|
1265
1601
|
communicator = WebsocketCommunicator(application, "/FWSTAT/")
|
|
1266
|
-
|
|
1267
|
-
|
|
1602
|
+
try:
|
|
1603
|
+
connected, detail = await communicator.connect()
|
|
1604
|
+
self.assertTrue(connected, detail)
|
|
1268
1605
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
"
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1606
|
+
deployment_pk = await database_sync_to_async(
|
|
1607
|
+
self._create_firmware_deployment
|
|
1608
|
+
)("FWSTAT")
|
|
1609
|
+
ts = timezone.now().replace(microsecond=0)
|
|
1610
|
+
payload = {
|
|
1611
|
+
"status": "Installing",
|
|
1612
|
+
"statusInfo": "Applying patch",
|
|
1613
|
+
"timestamp": ts.isoformat(),
|
|
1614
|
+
}
|
|
1275
1615
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1616
|
+
await communicator.send_json_to(
|
|
1617
|
+
[2, "1", "FirmwareStatusNotification", payload]
|
|
1618
|
+
)
|
|
1619
|
+
response = await communicator.receive_json_from()
|
|
1620
|
+
self.assertEqual(response, [3, "1", {}])
|
|
1281
1621
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1622
|
+
def _fetch_status():
|
|
1623
|
+
charger = Charger.objects.get(
|
|
1624
|
+
charger_id="FWSTAT", connector_id=None
|
|
1625
|
+
)
|
|
1626
|
+
return (
|
|
1627
|
+
charger.firmware_status,
|
|
1628
|
+
charger.firmware_status_info,
|
|
1629
|
+
charger.firmware_timestamp,
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
status, info, recorded_ts = await database_sync_to_async(
|
|
1633
|
+
_fetch_status
|
|
1634
|
+
)()
|
|
1635
|
+
self.assertEqual(status, "Installing")
|
|
1636
|
+
self.assertEqual(info, "Applying patch")
|
|
1637
|
+
self.assertIsNotNone(recorded_ts)
|
|
1638
|
+
self.assertEqual(recorded_ts.replace(microsecond=0), ts)
|
|
1639
|
+
|
|
1640
|
+
def _fetch_deployment():
|
|
1641
|
+
return CPFirmwareDeployment.objects.get(pk=deployment_pk)
|
|
1642
|
+
|
|
1643
|
+
deployment = await database_sync_to_async(_fetch_deployment)()
|
|
1644
|
+
self.assertEqual(deployment.status, "Installing")
|
|
1645
|
+
self.assertEqual(deployment.status_info, "Applying patch")
|
|
1646
|
+
self.assertIsNotNone(deployment.status_timestamp)
|
|
1647
|
+
self.assertEqual(
|
|
1648
|
+
deployment.status_timestamp.replace(microsecond=0), ts
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
log_entries = store.get_logs(
|
|
1652
|
+
store.identity_key("FWSTAT", None), log_type="charger"
|
|
1653
|
+
)
|
|
1654
|
+
self.assertTrue(
|
|
1655
|
+
any("FirmwareStatusNotification" in entry for entry in log_entries)
|
|
1288
1656
|
)
|
|
1289
1657
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1658
|
+
def _fetch_views():
|
|
1659
|
+
User = get_user_model()
|
|
1660
|
+
user = User.objects.create_user(username="fwstatus", password="pw")
|
|
1661
|
+
client = Client()
|
|
1662
|
+
client.force_login(user)
|
|
1663
|
+
detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
|
|
1664
|
+
status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
|
|
1665
|
+
list_response = client.get(reverse("charger-list"))
|
|
1666
|
+
return (
|
|
1667
|
+
detail.status_code,
|
|
1668
|
+
json.loads(detail.content.decode()),
|
|
1669
|
+
status_page.status_code,
|
|
1670
|
+
status_page.content.decode(),
|
|
1671
|
+
list_response.status_code,
|
|
1672
|
+
json.loads(list_response.content.decode()),
|
|
1673
|
+
)
|
|
1295
1674
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1675
|
+
(
|
|
1676
|
+
detail_code,
|
|
1677
|
+
detail_payload,
|
|
1678
|
+
status_code,
|
|
1679
|
+
html,
|
|
1680
|
+
list_code,
|
|
1681
|
+
list_payload,
|
|
1682
|
+
) = await database_sync_to_async(_fetch_views)()
|
|
1683
|
+
self.assertEqual(detail_code, 200)
|
|
1684
|
+
self.assertEqual(status_code, 200)
|
|
1685
|
+
self.assertEqual(list_code, 200)
|
|
1686
|
+
self.assertEqual(detail_payload["firmwareStatus"], "Installing")
|
|
1687
|
+
self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
|
|
1688
|
+
self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
|
|
1689
|
+
self.assertNotIn('id="firmware-status"', html)
|
|
1690
|
+
self.assertNotIn('id="firmware-status-info"', html)
|
|
1691
|
+
self.assertNotIn('id="firmware-timestamp"', html)
|
|
1692
|
+
|
|
1693
|
+
matching = [
|
|
1694
|
+
item
|
|
1695
|
+
for item in list_payload.get("chargers", [])
|
|
1696
|
+
if item["charger_id"] == "FWSTAT"
|
|
1697
|
+
and item["connector_id"] is None
|
|
1698
|
+
]
|
|
1699
|
+
self.assertTrue(matching)
|
|
1700
|
+
self.assertEqual(matching[0]["firmwareStatus"], "Installing")
|
|
1701
|
+
self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
|
|
1702
|
+
list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
|
|
1703
|
+
self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
|
|
1704
|
+
|
|
1705
|
+
store.clear_log(
|
|
1706
|
+
store.identity_key("FWSTAT", None), log_type="charger"
|
|
1707
|
+
)
|
|
1708
|
+
finally:
|
|
1709
|
+
with suppress(Exception):
|
|
1710
|
+
await communicator.disconnect()
|
|
1711
|
+
store.MAX_CONNECTIONS_PER_IP = limit
|
|
1712
|
+
|
|
1713
|
+
async def test_update_firmware_call_result_updates_deployment(self):
|
|
1714
|
+
store.ip_connections.clear()
|
|
1715
|
+
limit = store.MAX_CONNECTIONS_PER_IP
|
|
1716
|
+
store.MAX_CONNECTIONS_PER_IP = 10
|
|
1717
|
+
charger = await database_sync_to_async(Charger.objects.create)(
|
|
1718
|
+
charger_id="UPFW", connector_id=None
|
|
1719
|
+
)
|
|
1720
|
+
firmware = await database_sync_to_async(CPFirmware.objects.create)(
|
|
1721
|
+
name="Update firmware",
|
|
1722
|
+
filename="update.bin",
|
|
1723
|
+
payload_binary=b"bin",
|
|
1724
|
+
content_type="application/octet-stream",
|
|
1725
|
+
source=CPFirmware.Source.UPLOAD,
|
|
1726
|
+
is_user_data=True,
|
|
1727
|
+
)
|
|
1728
|
+
deployment = await database_sync_to_async(CPFirmwareDeployment.objects.create)(
|
|
1729
|
+
firmware=firmware,
|
|
1730
|
+
charger=charger,
|
|
1731
|
+
node=charger.node_origin,
|
|
1732
|
+
ocpp_message_id="upfw-msg",
|
|
1733
|
+
status="Pending",
|
|
1734
|
+
status_info="",
|
|
1735
|
+
status_timestamp=timezone.now(),
|
|
1736
|
+
retrieve_date=timezone.now(),
|
|
1737
|
+
request_payload={},
|
|
1738
|
+
is_user_data=True,
|
|
1299
1739
|
)
|
|
1300
1740
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
json.loads(detail.content.decode()),
|
|
1312
|
-
status_page.status_code,
|
|
1313
|
-
status_page.content.decode(),
|
|
1314
|
-
list_response.status_code,
|
|
1315
|
-
json.loads(list_response.content.decode()),
|
|
1316
|
-
)
|
|
1741
|
+
message_id = "firmware-update"
|
|
1742
|
+
store.register_pending_call(
|
|
1743
|
+
message_id,
|
|
1744
|
+
{
|
|
1745
|
+
"action": "UpdateFirmware",
|
|
1746
|
+
"charger_id": "UPFW",
|
|
1747
|
+
"connector_id": None,
|
|
1748
|
+
"deployment_pk": deployment.pk,
|
|
1749
|
+
},
|
|
1750
|
+
)
|
|
1317
1751
|
|
|
1318
|
-
(
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
html,
|
|
1323
|
-
list_code,
|
|
1324
|
-
list_payload,
|
|
1325
|
-
) = await database_sync_to_async(_fetch_views)()
|
|
1326
|
-
self.assertEqual(detail_code, 200)
|
|
1327
|
-
self.assertEqual(status_code, 200)
|
|
1328
|
-
self.assertEqual(list_code, 200)
|
|
1329
|
-
self.assertEqual(detail_payload["firmwareStatus"], "Installing")
|
|
1330
|
-
self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
|
|
1331
|
-
self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
|
|
1332
|
-
self.assertNotIn('id="firmware-status"', html)
|
|
1333
|
-
self.assertNotIn('id="firmware-status-info"', html)
|
|
1334
|
-
self.assertNotIn('id="firmware-timestamp"', html)
|
|
1335
|
-
|
|
1336
|
-
matching = [
|
|
1337
|
-
item
|
|
1338
|
-
for item in list_payload.get("chargers", [])
|
|
1339
|
-
if item["charger_id"] == "FWSTAT" and item["connector_id"] is None
|
|
1340
|
-
]
|
|
1341
|
-
self.assertTrue(matching)
|
|
1342
|
-
self.assertEqual(matching[0]["firmwareStatus"], "Installing")
|
|
1343
|
-
self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
|
|
1344
|
-
list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
|
|
1345
|
-
self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
|
|
1752
|
+
communicator = WebsocketCommunicator(application, "/UPFW/")
|
|
1753
|
+
try:
|
|
1754
|
+
connected, detail = await communicator.connect()
|
|
1755
|
+
self.assertTrue(connected, detail)
|
|
1346
1756
|
|
|
1347
|
-
|
|
1757
|
+
await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
|
|
1348
1758
|
|
|
1349
|
-
|
|
1759
|
+
await asyncio.sleep(0.05)
|
|
1760
|
+
|
|
1761
|
+
updated = await database_sync_to_async(CPFirmwareDeployment.objects.get)(
|
|
1762
|
+
pk=deployment.pk
|
|
1763
|
+
)
|
|
1764
|
+
self.assertEqual(updated.status, "Accepted")
|
|
1765
|
+
self.assertIsNotNone(updated.status_timestamp)
|
|
1766
|
+
self.assertFalse(updated.completed_at)
|
|
1767
|
+
finally:
|
|
1768
|
+
with suppress(Exception):
|
|
1769
|
+
await communicator.disconnect()
|
|
1770
|
+
store.MAX_CONNECTIONS_PER_IP = limit
|
|
1350
1771
|
|
|
1351
1772
|
async def test_firmware_status_notification_updates_connector_and_aggregate(
|
|
1352
1773
|
self,
|
|
@@ -1941,6 +2362,35 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1941
2362
|
data = json.loads(files[0].read_text())
|
|
1942
2363
|
self.assertTrue(any("StartTransaction" in m["message"] for m in data))
|
|
1943
2364
|
|
|
2365
|
+
def test_session_log_buffer_bounded(self):
|
|
2366
|
+
cid = "BUFFER-LIMIT"
|
|
2367
|
+
session_dir = Path("logs") / "sessions" / cid
|
|
2368
|
+
if session_dir.exists():
|
|
2369
|
+
for file_path in session_dir.glob("*.json"):
|
|
2370
|
+
file_path.unlink()
|
|
2371
|
+
|
|
2372
|
+
tx_id = 999
|
|
2373
|
+
store.start_session_log(cid, tx_id)
|
|
2374
|
+
self.addCleanup(lambda: store.history.pop(cid, None))
|
|
2375
|
+
|
|
2376
|
+
try:
|
|
2377
|
+
metadata = store.history[cid]
|
|
2378
|
+
path = metadata["path"]
|
|
2379
|
+
message_count = store.SESSION_LOG_BUFFER_LIMIT * 3 + 5
|
|
2380
|
+
for idx in range(message_count):
|
|
2381
|
+
store.add_session_message(cid, f"message {idx}")
|
|
2382
|
+
buffer = metadata["buffer"]
|
|
2383
|
+
self.assertLessEqual(len(buffer), store.SESSION_LOG_BUFFER_LIMIT)
|
|
2384
|
+
finally:
|
|
2385
|
+
store.end_session_log(cid)
|
|
2386
|
+
|
|
2387
|
+
self.assertTrue(path.exists())
|
|
2388
|
+
try:
|
|
2389
|
+
payload = json.loads(path.read_text())
|
|
2390
|
+
self.assertEqual(len(payload), message_count)
|
|
2391
|
+
finally:
|
|
2392
|
+
path.unlink(missing_ok=True)
|
|
2393
|
+
|
|
1944
2394
|
async def test_second_connection_closes_first(self):
|
|
1945
2395
|
communicator1 = WebsocketCommunicator(application, "/DUPLICATE/")
|
|
1946
2396
|
connected, _ = await communicator1.connect()
|
|
@@ -2923,6 +3373,101 @@ class ChargerAdminTests(TestCase):
|
|
|
2923
3373
|
store.clear_log(log_key, log_type="charger")
|
|
2924
3374
|
store.clear_log(pending_key, log_type="charger")
|
|
2925
3375
|
|
|
3376
|
+
def test_get_diagnostics_downloads_file_to_work_directory(self):
|
|
3377
|
+
charger = Charger.objects.create(
|
|
3378
|
+
charger_id="DIAGADMIN",
|
|
3379
|
+
diagnostics_location="https://example.com/diag.tar.gz",
|
|
3380
|
+
diagnostics_status="Uploaded",
|
|
3381
|
+
)
|
|
3382
|
+
fixed_now = datetime(2024, 1, 2, 3, 4, 5, tzinfo=dt_timezone.utc)
|
|
3383
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
3384
|
+
base_path = Path(tempdir)
|
|
3385
|
+
response_mock = Mock()
|
|
3386
|
+
response_mock.status_code = 200
|
|
3387
|
+
response_mock.iter_content.return_value = [b"diagnostics"]
|
|
3388
|
+
response_mock.headers = {
|
|
3389
|
+
"Content-Disposition": 'attachment; filename="diagnostics.tar.gz"'
|
|
3390
|
+
}
|
|
3391
|
+
response_mock.close = Mock()
|
|
3392
|
+
with override_settings(BASE_DIR=base_path):
|
|
3393
|
+
with patch("ocpp.admin.requests.get", return_value=response_mock) as mock_get:
|
|
3394
|
+
with patch("ocpp.admin.timezone.now", return_value=fixed_now):
|
|
3395
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3396
|
+
response = self.client.post(
|
|
3397
|
+
url,
|
|
3398
|
+
{
|
|
3399
|
+
"action": "get_diagnostics",
|
|
3400
|
+
"_selected_action": [charger.pk],
|
|
3401
|
+
},
|
|
3402
|
+
follow=True,
|
|
3403
|
+
)
|
|
3404
|
+
self.assertEqual(response.status_code, 200)
|
|
3405
|
+
work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
|
|
3406
|
+
self.assertTrue(work_dir.exists())
|
|
3407
|
+
files = list(work_dir.glob("*"))
|
|
3408
|
+
self.assertEqual(len(files), 1)
|
|
3409
|
+
saved_file = files[0]
|
|
3410
|
+
self.assertEqual(saved_file.read_bytes(), b"diagnostics")
|
|
3411
|
+
asset_path = saved_file.relative_to(base_path / "work" / "ocpp-admin").as_posix()
|
|
3412
|
+
asset_url = "http://testserver" + reverse(
|
|
3413
|
+
"pages:readme-asset", kwargs={"source": "work", "asset": asset_path}
|
|
3414
|
+
)
|
|
3415
|
+
self.assertContains(response, asset_url)
|
|
3416
|
+
self.assertContains(response, str(saved_file))
|
|
3417
|
+
mock_get.assert_called_once_with(
|
|
3418
|
+
"https://example.com/diag.tar.gz", stream=True, timeout=15
|
|
3419
|
+
)
|
|
3420
|
+
response_mock.close.assert_called_once()
|
|
3421
|
+
|
|
3422
|
+
def test_get_diagnostics_requires_location_reports_warning(self):
|
|
3423
|
+
charger = Charger.objects.create(charger_id="DIAGNOLOC")
|
|
3424
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
3425
|
+
base_path = Path(tempdir)
|
|
3426
|
+
with override_settings(BASE_DIR=base_path):
|
|
3427
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3428
|
+
response = self.client.post(
|
|
3429
|
+
url,
|
|
3430
|
+
{"action": "get_diagnostics", "_selected_action": [charger.pk]},
|
|
3431
|
+
follow=True,
|
|
3432
|
+
)
|
|
3433
|
+
self.assertEqual(response.status_code, 200)
|
|
3434
|
+
self.assertContains(response, "DIAGNOLOC: no diagnostics location reported.")
|
|
3435
|
+
work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
|
|
3436
|
+
self.assertTrue(work_dir.exists())
|
|
3437
|
+
self.assertFalse(list(work_dir.iterdir()))
|
|
3438
|
+
|
|
3439
|
+
def test_get_diagnostics_handles_download_error(self):
|
|
3440
|
+
charger = Charger.objects.create(
|
|
3441
|
+
charger_id="DIAGFAIL",
|
|
3442
|
+
diagnostics_location="https://example.com/diag.tar",
|
|
3443
|
+
)
|
|
3444
|
+
response_mock = Mock()
|
|
3445
|
+
response_mock.status_code = 500
|
|
3446
|
+
response_mock.iter_content.return_value = []
|
|
3447
|
+
response_mock.headers = {}
|
|
3448
|
+
response_mock.close = Mock()
|
|
3449
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
3450
|
+
base_path = Path(tempdir)
|
|
3451
|
+
with override_settings(BASE_DIR=base_path):
|
|
3452
|
+
with patch("ocpp.admin.requests.get", return_value=response_mock):
|
|
3453
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3454
|
+
response = self.client.post(
|
|
3455
|
+
url,
|
|
3456
|
+
{
|
|
3457
|
+
"action": "get_diagnostics",
|
|
3458
|
+
"_selected_action": [charger.pk],
|
|
3459
|
+
},
|
|
3460
|
+
follow=True,
|
|
3461
|
+
)
|
|
3462
|
+
self.assertEqual(response.status_code, 200)
|
|
3463
|
+
self.assertContains(
|
|
3464
|
+
response, "DIAGFAIL: Diagnostics download returned status 500."
|
|
3465
|
+
)
|
|
3466
|
+
work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
|
|
3467
|
+
self.assertTrue(work_dir.exists())
|
|
3468
|
+
self.assertFalse(list(work_dir.iterdir()))
|
|
3469
|
+
response_mock.close.assert_called_once()
|
|
3470
|
+
|
|
2926
3471
|
|
|
2927
3472
|
class ChargerConfigurationAdminUnitTests(TestCase):
|
|
2928
3473
|
def setUp(self):
|
|
@@ -2952,6 +3497,38 @@ class ChargerConfigurationAdminUnitTests(TestCase):
|
|
|
2952
3497
|
configuration.refresh_from_db()
|
|
2953
3498
|
self.assertIsNone(configuration.evcs_snapshot_at)
|
|
2954
3499
|
|
|
3500
|
+
def test_configuration_key_inline_readonly_helpers(self):
|
|
3501
|
+
configuration = ChargerConfiguration.objects.create(
|
|
3502
|
+
charger_identifier="CFG-INLINE"
|
|
3503
|
+
)
|
|
3504
|
+
configuration.replace_configuration_keys(
|
|
3505
|
+
[
|
|
3506
|
+
{
|
|
3507
|
+
"key": "HeartbeatInterval",
|
|
3508
|
+
"value": {"interval": 300},
|
|
3509
|
+
"readonly": True,
|
|
3510
|
+
"note": "Check",
|
|
3511
|
+
},
|
|
3512
|
+
{"key": "AuthorizeRemoteTxRequests", "readonly": False},
|
|
3513
|
+
]
|
|
3514
|
+
)
|
|
3515
|
+
inline = ConfigurationKeyInline(ChargerConfiguration, self.admin.admin_site)
|
|
3516
|
+
entries = list(
|
|
3517
|
+
configuration.configuration_entries.order_by("position", "id")
|
|
3518
|
+
)
|
|
3519
|
+
self.assertEqual(len(entries), 2)
|
|
3520
|
+
self.assertIn("<pre>", inline.value_display(entries[0]))
|
|
3521
|
+
self.assertIn("\"note\"", inline.extra_display(entries[0]))
|
|
3522
|
+
self.assertEqual(inline.value_display(entries[1]), "-")
|
|
3523
|
+
self.assertEqual(inline.extra_display(entries[1]), "-")
|
|
3524
|
+
request = self.request_factory.get("/admin/ocpp/chargerconfiguration/")
|
|
3525
|
+
self.assertFalse(inline.has_add_permission(request, configuration))
|
|
3526
|
+
|
|
3527
|
+
def test_configuration_key_admin_hidden_from_index(self):
|
|
3528
|
+
key_admin = ConfigurationKeyAdmin(ConfigurationKey, AdminSite())
|
|
3529
|
+
perms = key_admin.get_model_perms(self.request_factory.get("/"))
|
|
3530
|
+
self.assertEqual(perms, {})
|
|
3531
|
+
|
|
2955
3532
|
|
|
2956
3533
|
class ConfigurationTaskTests(TestCase):
|
|
2957
3534
|
def tearDown(self):
|
|
@@ -4081,7 +4658,9 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
4081
4658
|
cfg = SimulatorConfig(cp_path="SIMLOG/")
|
|
4082
4659
|
sim = ChargePointSimulator(cfg)
|
|
4083
4660
|
store.clear_log(cfg.cp_path, log_type="simulator")
|
|
4084
|
-
store.logs["simulator"][cfg.cp_path] =
|
|
4661
|
+
store.logs["simulator"][cfg.cp_path] = deque(
|
|
4662
|
+
maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES
|
|
4663
|
+
)
|
|
4085
4664
|
sent_frames: list[str] = []
|
|
4086
4665
|
|
|
4087
4666
|
async def send(payload: str) -> None:
|
|
@@ -4422,6 +5001,28 @@ class DailySessionReportTaskTests(TestCase):
|
|
|
4422
5001
|
except FileNotFoundError:
|
|
4423
5002
|
pass
|
|
4424
5003
|
|
|
5004
|
+
def _create_transaction_with_reading(
|
|
5005
|
+
self,
|
|
5006
|
+
charger: Charger,
|
|
5007
|
+
start: datetime,
|
|
5008
|
+
energy: Decimal,
|
|
5009
|
+
) -> Transaction:
|
|
5010
|
+
transaction = Transaction.objects.create(
|
|
5011
|
+
charger=charger,
|
|
5012
|
+
start_time=start,
|
|
5013
|
+
stop_time=start + timedelta(minutes=30),
|
|
5014
|
+
meter_start=0,
|
|
5015
|
+
connector_id=1,
|
|
5016
|
+
)
|
|
5017
|
+
MeterValue.objects.create(
|
|
5018
|
+
charger=charger,
|
|
5019
|
+
connector_id=1,
|
|
5020
|
+
transaction=transaction,
|
|
5021
|
+
timestamp=start + timedelta(minutes=15),
|
|
5022
|
+
energy=energy,
|
|
5023
|
+
)
|
|
5024
|
+
return transaction
|
|
5025
|
+
|
|
4425
5026
|
def test_report_sends_email_when_sessions_exist(self):
|
|
4426
5027
|
User = get_user_model()
|
|
4427
5028
|
User.objects.create_superuser(
|
|
@@ -4475,6 +5076,41 @@ class DailySessionReportTaskTests(TestCase):
|
|
|
4475
5076
|
self.assertEqual(count, 0)
|
|
4476
5077
|
mock_send.assert_not_called()
|
|
4477
5078
|
|
|
5079
|
+
def test_report_query_count_constant(self):
|
|
5080
|
+
User = get_user_model()
|
|
5081
|
+
User.objects.create_superuser(
|
|
5082
|
+
username="report-admin", email="report-admin@example.com", password="pw"
|
|
5083
|
+
)
|
|
5084
|
+
charger = Charger.objects.create(charger_id="RPT-QUERY", display_name="Pod Q")
|
|
5085
|
+
base_start = timezone.now().replace(second=0, microsecond=0)
|
|
5086
|
+
|
|
5087
|
+
self._create_transaction_with_reading(
|
|
5088
|
+
charger, base_start, Decimal("1.2")
|
|
5089
|
+
)
|
|
5090
|
+
|
|
5091
|
+
with patch("core.mailer.can_send_email", return_value=True), patch(
|
|
5092
|
+
"core.mailer.send"
|
|
5093
|
+
) as mock_send, CaptureQueriesContext(connection) as ctx_single:
|
|
5094
|
+
count_single = send_daily_session_report()
|
|
5095
|
+
|
|
5096
|
+
self.assertEqual(count_single, 1)
|
|
5097
|
+
mock_send.assert_called_once()
|
|
5098
|
+
single_query_count = len(ctx_single)
|
|
5099
|
+
|
|
5100
|
+
later_start = base_start + timedelta(hours=1)
|
|
5101
|
+
self._create_transaction_with_reading(
|
|
5102
|
+
charger, later_start, Decimal("3.4")
|
|
5103
|
+
)
|
|
5104
|
+
|
|
5105
|
+
with patch("core.mailer.can_send_email", return_value=True), patch(
|
|
5106
|
+
"core.mailer.send"
|
|
5107
|
+
) as mock_send_multi, CaptureQueriesContext(connection) as ctx_multi:
|
|
5108
|
+
count_multi = send_daily_session_report()
|
|
5109
|
+
|
|
5110
|
+
self.assertEqual(count_multi, 2)
|
|
5111
|
+
mock_send_multi.assert_called_once()
|
|
5112
|
+
self.assertEqual(len(ctx_multi), single_query_count)
|
|
5113
|
+
|
|
4478
5114
|
|
|
4479
5115
|
class TransactionKwTests(TestCase):
|
|
4480
5116
|
def test_kw_sums_meter_readings(self):
|
|
@@ -4587,6 +5223,9 @@ class DispatchActionViewTests(TestCase):
|
|
|
4587
5223
|
)
|
|
4588
5224
|
self.mock_wait = self.wait_patch.start()
|
|
4589
5225
|
self.addCleanup(self.wait_patch.stop)
|
|
5226
|
+
self.schedule_patch = patch("ocpp.views.store.schedule_call_timeout")
|
|
5227
|
+
self.mock_schedule = self.schedule_patch.start()
|
|
5228
|
+
self.addCleanup(self.schedule_patch.stop)
|
|
4590
5229
|
|
|
4591
5230
|
def _close_loop(self):
|
|
4592
5231
|
try:
|
|
@@ -4659,6 +5298,75 @@ class DispatchActionViewTests(TestCase):
|
|
|
4659
5298
|
self.assertEqual(self.charger.availability_request_status, "")
|
|
4660
5299
|
self.assertNotIn(frame[1], store.pending_calls)
|
|
4661
5300
|
|
|
5301
|
+
def test_clear_cache_dispatches_frame_and_schedules_timeout(self):
|
|
5302
|
+
with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
|
|
5303
|
+
response = self.client.post(
|
|
5304
|
+
self.url,
|
|
5305
|
+
data=json.dumps({"action": "clear_cache"}),
|
|
5306
|
+
content_type="application/json",
|
|
5307
|
+
)
|
|
5308
|
+
self.assertEqual(response.status_code, 200)
|
|
5309
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5310
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
5311
|
+
frame = json.loads(self.ws.sent[0])
|
|
5312
|
+
self.assertEqual(frame[0], 2)
|
|
5313
|
+
self.assertEqual(frame[2], "ClearCache")
|
|
5314
|
+
self.assertEqual(frame[3], {})
|
|
5315
|
+
mock_timeout.assert_called_once()
|
|
5316
|
+
timeout_call = mock_timeout.call_args
|
|
5317
|
+
self.assertIsNotNone(timeout_call)
|
|
5318
|
+
self.assertEqual(timeout_call.args[0], frame[1])
|
|
5319
|
+
self.assertEqual(timeout_call.kwargs.get("action"), "ClearCache")
|
|
5320
|
+
log_entries = store.logs["charger"].get(self.log_key, [])
|
|
5321
|
+
self.assertTrue(any("ClearCache" in entry for entry in log_entries))
|
|
5322
|
+
|
|
5323
|
+
def test_clear_cache_allows_rejected_status(self):
|
|
5324
|
+
def wait_rejected(message_id, timeout=5.0):
|
|
5325
|
+
metadata = store.pending_calls.pop(message_id, None)
|
|
5326
|
+
store._pending_call_events.pop(message_id, None)
|
|
5327
|
+
store._pending_call_results.pop(message_id, None)
|
|
5328
|
+
return {
|
|
5329
|
+
"success": True,
|
|
5330
|
+
"payload": {"status": "Rejected"},
|
|
5331
|
+
"metadata": dict(metadata or {}),
|
|
5332
|
+
}
|
|
5333
|
+
|
|
5334
|
+
self.mock_wait.side_effect = wait_rejected
|
|
5335
|
+
with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
|
|
5336
|
+
response = self.client.post(
|
|
5337
|
+
self.url,
|
|
5338
|
+
data=json.dumps({"action": "clear_cache"}),
|
|
5339
|
+
content_type="application/json",
|
|
5340
|
+
)
|
|
5341
|
+
self.assertEqual(response.status_code, 200)
|
|
5342
|
+
payload = response.json()
|
|
5343
|
+
self.assertIn("sent", payload)
|
|
5344
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5345
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
5346
|
+
frame = json.loads(self.ws.sent[0])
|
|
5347
|
+
self.assertEqual(frame[2], "ClearCache")
|
|
5348
|
+
mock_timeout.assert_called_once()
|
|
5349
|
+
self.mock_wait.side_effect = self._wait_success
|
|
5350
|
+
|
|
5351
|
+
def test_clear_cache_reports_timeout(self):
|
|
5352
|
+
def no_response(message_id, timeout=5.0):
|
|
5353
|
+
return None
|
|
5354
|
+
|
|
5355
|
+
self.mock_wait.side_effect = no_response
|
|
5356
|
+
with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
|
|
5357
|
+
response = self.client.post(
|
|
5358
|
+
self.url,
|
|
5359
|
+
data=json.dumps({"action": "clear_cache"}),
|
|
5360
|
+
content_type="application/json",
|
|
5361
|
+
)
|
|
5362
|
+
self.assertEqual(response.status_code, 504)
|
|
5363
|
+
detail = response.json().get("detail", "")
|
|
5364
|
+
self.assertIn("did not receive", detail)
|
|
5365
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5366
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
5367
|
+
mock_timeout.assert_called_once()
|
|
5368
|
+
self.mock_wait.side_effect = self._wait_success
|
|
5369
|
+
|
|
4662
5370
|
def test_remote_start_reports_rejection(self):
|
|
4663
5371
|
def rejected(message_id, timeout=5.0):
|
|
4664
5372
|
metadata = store.pending_calls.pop(message_id, None)
|
|
@@ -4721,6 +5429,118 @@ class DispatchActionViewTests(TestCase):
|
|
|
4721
5429
|
self.assertIn("did not receive", detail)
|
|
4722
5430
|
self.mock_wait.side_effect = self._wait_success
|
|
4723
5431
|
|
|
5432
|
+
def test_change_configuration_requires_key(self):
|
|
5433
|
+
self.mock_schedule.reset_mock()
|
|
5434
|
+
response = self.client.post(
|
|
5435
|
+
self.url,
|
|
5436
|
+
data=json.dumps({"action": "change_configuration"}),
|
|
5437
|
+
content_type="application/json",
|
|
5438
|
+
)
|
|
5439
|
+
self.assertEqual(response.status_code, 400)
|
|
5440
|
+
self.assertEqual(response.json().get("detail"), "key required")
|
|
5441
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5442
|
+
self.assertEqual(self.ws.sent, [])
|
|
5443
|
+
self.mock_schedule.assert_not_called()
|
|
5444
|
+
|
|
5445
|
+
def test_change_configuration_rejects_invalid_value_type(self):
|
|
5446
|
+
self.mock_schedule.reset_mock()
|
|
5447
|
+
response = self.client.post(
|
|
5448
|
+
self.url,
|
|
5449
|
+
data=json.dumps(
|
|
5450
|
+
{
|
|
5451
|
+
"action": "change_configuration",
|
|
5452
|
+
"key": "HeartbeatInterval",
|
|
5453
|
+
"value": {"unexpected": "object"},
|
|
5454
|
+
}
|
|
5455
|
+
),
|
|
5456
|
+
content_type="application/json",
|
|
5457
|
+
)
|
|
5458
|
+
self.assertEqual(response.status_code, 400)
|
|
5459
|
+
self.assertIn("value must", response.json().get("detail", ""))
|
|
5460
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5461
|
+
self.assertEqual(self.ws.sent, [])
|
|
5462
|
+
self.mock_schedule.assert_not_called()
|
|
5463
|
+
|
|
5464
|
+
def test_change_configuration_dispatches_frame(self):
|
|
5465
|
+
self.mock_schedule.reset_mock()
|
|
5466
|
+
response = self.client.post(
|
|
5467
|
+
self.url,
|
|
5468
|
+
data=json.dumps(
|
|
5469
|
+
{
|
|
5470
|
+
"action": "change_configuration",
|
|
5471
|
+
"key": "HeartbeatInterval",
|
|
5472
|
+
"value": "120",
|
|
5473
|
+
}
|
|
5474
|
+
),
|
|
5475
|
+
content_type="application/json",
|
|
5476
|
+
)
|
|
5477
|
+
self.assertEqual(response.status_code, 200)
|
|
5478
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5479
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
5480
|
+
frame = json.loads(self.ws.sent[0])
|
|
5481
|
+
self.assertEqual(frame[0], 2)
|
|
5482
|
+
self.assertEqual(frame[2], "ChangeConfiguration")
|
|
5483
|
+
self.assertEqual(frame[3]["key"], "HeartbeatInterval")
|
|
5484
|
+
self.assertEqual(frame[3]["value"], "120")
|
|
5485
|
+
self.mock_schedule.assert_called_once()
|
|
5486
|
+
log_entries = store.logs["charger"].get(self.log_key, [])
|
|
5487
|
+
self.assertTrue(
|
|
5488
|
+
any("Requested configuration change" in entry for entry in log_entries)
|
|
5489
|
+
)
|
|
5490
|
+
|
|
5491
|
+
def test_change_configuration_reports_rejection(self):
|
|
5492
|
+
self.mock_schedule.reset_mock()
|
|
5493
|
+
|
|
5494
|
+
def rejected(message_id, timeout=5.0):
|
|
5495
|
+
metadata = store.pending_calls.pop(message_id, None)
|
|
5496
|
+
store._pending_call_events.pop(message_id, None)
|
|
5497
|
+
store._pending_call_results.pop(message_id, None)
|
|
5498
|
+
return {
|
|
5499
|
+
"success": True,
|
|
5500
|
+
"payload": {"status": "Rejected"},
|
|
5501
|
+
"metadata": dict(metadata or {}),
|
|
5502
|
+
}
|
|
5503
|
+
|
|
5504
|
+
self.mock_wait.side_effect = rejected
|
|
5505
|
+
response = self.client.post(
|
|
5506
|
+
self.url,
|
|
5507
|
+
data=json.dumps(
|
|
5508
|
+
{
|
|
5509
|
+
"action": "change_configuration",
|
|
5510
|
+
"key": "HeartbeatInterval",
|
|
5511
|
+
"value": "120",
|
|
5512
|
+
}
|
|
5513
|
+
),
|
|
5514
|
+
content_type="application/json",
|
|
5515
|
+
)
|
|
5516
|
+
self.assertEqual(response.status_code, 400)
|
|
5517
|
+
detail = response.json().get("detail", "")
|
|
5518
|
+
self.assertIn("Rejected", detail)
|
|
5519
|
+
self.mock_wait.side_effect = self._wait_success
|
|
5520
|
+
|
|
5521
|
+
def test_change_configuration_reports_timeout(self):
|
|
5522
|
+
self.mock_schedule.reset_mock()
|
|
5523
|
+
|
|
5524
|
+
def no_response(message_id, timeout=5.0):
|
|
5525
|
+
return None
|
|
5526
|
+
|
|
5527
|
+
self.mock_wait.side_effect = no_response
|
|
5528
|
+
response = self.client.post(
|
|
5529
|
+
self.url,
|
|
5530
|
+
data=json.dumps(
|
|
5531
|
+
{
|
|
5532
|
+
"action": "change_configuration",
|
|
5533
|
+
"key": "HeartbeatInterval",
|
|
5534
|
+
"value": "120",
|
|
5535
|
+
}
|
|
5536
|
+
),
|
|
5537
|
+
content_type="application/json",
|
|
5538
|
+
)
|
|
5539
|
+
self.assertEqual(response.status_code, 504)
|
|
5540
|
+
detail = response.json().get("detail", "")
|
|
5541
|
+
self.assertIn("did not receive", detail)
|
|
5542
|
+
self.mock_wait.side_effect = self._wait_success
|
|
5543
|
+
|
|
4724
5544
|
def test_change_availability_requires_valid_type(self):
|
|
4725
5545
|
response = self.client.post(
|
|
4726
5546
|
self.url,
|
|
@@ -4728,6 +5548,9 @@ class DispatchActionViewTests(TestCase):
|
|
|
4728
5548
|
content_type="application/json",
|
|
4729
5549
|
)
|
|
4730
5550
|
self.assertEqual(response.status_code, 400)
|
|
5551
|
+
self.assertEqual(
|
|
5552
|
+
response.json().get("detail"), "invalid availability type"
|
|
5553
|
+
)
|
|
4731
5554
|
self.loop.run_until_complete(asyncio.sleep(0))
|
|
4732
5555
|
self.assertEqual(self.ws.sent, [])
|
|
4733
5556
|
self.assertFalse(store.pending_calls)
|
|
@@ -4857,11 +5680,14 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4857
5680
|
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4858
5681
|
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4859
5682
|
|
|
4860
|
-
store.logs["charger"][log_key] =
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
5683
|
+
store.logs["charger"][log_key] = deque(
|
|
5684
|
+
[
|
|
5685
|
+
build_entry(timedelta(days=2), "Available"),
|
|
5686
|
+
build_entry(timedelta(days=1), "Charging"),
|
|
5687
|
+
build_entry(timedelta(hours=12), "Available"),
|
|
5688
|
+
],
|
|
5689
|
+
maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES,
|
|
5690
|
+
)
|
|
4865
5691
|
|
|
4866
5692
|
data, _window = _usage_timeline(charger, [], now=fixed_now)
|
|
4867
5693
|
self.assertEqual(len(data), 1)
|
|
@@ -4898,7 +5724,9 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4898
5724
|
}
|
|
4899
5725
|
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4900
5726
|
key = store.identity_key(aggregate.charger_id, connector_id)
|
|
4901
|
-
store.logs["charger"].setdefault(
|
|
5727
|
+
store.logs["charger"].setdefault(
|
|
5728
|
+
key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
|
|
5729
|
+
).append(
|
|
4902
5730
|
f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4903
5731
|
)
|
|
4904
5732
|
|
|
@@ -4939,13 +5767,16 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4939
5767
|
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4940
5768
|
|
|
4941
5769
|
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
4942
|
-
store.logs["charger"][log_key] =
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
5770
|
+
store.logs["charger"][log_key] = deque(
|
|
5771
|
+
[
|
|
5772
|
+
build_entry(timedelta(days=6, hours=12), "Available"),
|
|
5773
|
+
build_entry(timedelta(days=5), "Available"),
|
|
5774
|
+
build_entry(timedelta(days=3, hours=6), "Charging"),
|
|
5775
|
+
build_entry(timedelta(days=2), "Charging"),
|
|
5776
|
+
build_entry(timedelta(days=1), "Available"),
|
|
5777
|
+
],
|
|
5778
|
+
maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES,
|
|
5779
|
+
)
|
|
4949
5780
|
|
|
4950
5781
|
data, window = _usage_timeline(charger, [], now=fixed_now)
|
|
4951
5782
|
self.assertIsNotNone(window)
|
|
@@ -4973,6 +5804,37 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4973
5804
|
self.assertContains(resp, "id=\"diagnostics-location\"")
|
|
4974
5805
|
self.assertContains(resp, "https://example.com/report.tar")
|
|
4975
5806
|
|
|
5807
|
+
def test_firmware_download_serves_payload(self):
|
|
5808
|
+
charger = Charger.objects.create(charger_id="DLVIEW")
|
|
5809
|
+
firmware = CPFirmware.objects.create(
|
|
5810
|
+
name="Download",
|
|
5811
|
+
filename="download.bin",
|
|
5812
|
+
payload_binary=b"payload",
|
|
5813
|
+
content_type="application/octet-stream",
|
|
5814
|
+
source=CPFirmware.Source.DOWNLOAD,
|
|
5815
|
+
is_user_data=True,
|
|
5816
|
+
)
|
|
5817
|
+
deployment = CPFirmwareDeployment.objects.create(
|
|
5818
|
+
firmware=firmware,
|
|
5819
|
+
charger=charger,
|
|
5820
|
+
node=charger.node_origin,
|
|
5821
|
+
ocpp_message_id="dl-msg",
|
|
5822
|
+
status="Pending",
|
|
5823
|
+
status_info="",
|
|
5824
|
+
status_timestamp=timezone.now(),
|
|
5825
|
+
retrieve_date=timezone.now(),
|
|
5826
|
+
request_payload={},
|
|
5827
|
+
is_user_data=True,
|
|
5828
|
+
)
|
|
5829
|
+
token = deployment.issue_download_token(lifetime=timedelta(hours=1))
|
|
5830
|
+
response = self.client.get(
|
|
5831
|
+
reverse("cp-firmware-download", args=[deployment.pk, token])
|
|
5832
|
+
)
|
|
5833
|
+
self.assertEqual(response.status_code, 200)
|
|
5834
|
+
self.assertEqual(response.content, b"payload")
|
|
5835
|
+
deployment.refresh_from_db()
|
|
5836
|
+
self.assertIsNotNone(deployment.downloaded_at)
|
|
5837
|
+
|
|
4976
5838
|
def test_connector_status_prefers_connector_diagnostics(self):
|
|
4977
5839
|
aggregate = Charger.objects.create(
|
|
4978
5840
|
charger_id="DIAGCONN",
|
|
@@ -5492,3 +6354,33 @@ class LiveUpdateViewTests(TestCase):
|
|
|
5492
6354
|
reverse("charger-status", args=[restricted.charger_id])
|
|
5493
6355
|
)
|
|
5494
6356
|
self.assertEqual(group_denied.status_code, 404)
|
|
6357
|
+
|
|
6358
|
+
|
|
6359
|
+
class StoreLogBufferTests(TestCase):
|
|
6360
|
+
def test_add_log_enforces_in_memory_cap(self):
|
|
6361
|
+
cid = "BUFFER-CAP-TEST"
|
|
6362
|
+
log_type = "charger"
|
|
6363
|
+
store.clear_log(cid, log_type=log_type)
|
|
6364
|
+
self.addCleanup(lambda: store.clear_log(cid, log_type=log_type))
|
|
6365
|
+
|
|
6366
|
+
with patch("ocpp.store.MAX_IN_MEMORY_LOG_ENTRIES", 3):
|
|
6367
|
+
for index in range(6):
|
|
6368
|
+
store.add_log(cid, f"message {index}", log_type=log_type)
|
|
6369
|
+
|
|
6370
|
+
buffer = None
|
|
6371
|
+
lower = cid.lower()
|
|
6372
|
+
for key, entries in store.logs[log_type].items():
|
|
6373
|
+
if key.lower() == lower:
|
|
6374
|
+
buffer = entries
|
|
6375
|
+
break
|
|
6376
|
+
|
|
6377
|
+
self.assertIsNotNone(buffer, "Expected in-memory log buffer to be created")
|
|
6378
|
+
self.assertIsInstance(buffer, deque)
|
|
6379
|
+
self.assertEqual(len(buffer), 3)
|
|
6380
|
+
self.assertTrue(buffer[0].endswith("message 3"))
|
|
6381
|
+
self.assertTrue(buffer[-1].endswith("message 5"))
|
|
6382
|
+
|
|
6383
|
+
merged = store.get_logs(cid, log_type=log_type)
|
|
6384
|
+
|
|
6385
|
+
self.assertTrue(any(entry.endswith("message 5") for entry in merged))
|
|
6386
|
+
self.assertTrue(any(entry.endswith("message 4") for entry in merged))
|