arthexis 0.1.11__py3-none-any.whl → 0.1.12__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.

ocpp/tests.py CHANGED
@@ -2,33 +2,53 @@ import os
2
2
 
3
3
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
4
 
5
+ import time
6
+
7
+ import tests.conftest # noqa: F401
8
+
5
9
  import django
6
10
 
11
+ django.setup = tests.conftest._original_setup
7
12
  django.setup()
8
13
 
9
14
  from asgiref.testing import ApplicationCommunicator
10
15
  from channels.testing import WebsocketCommunicator
11
16
  from channels.db import database_sync_to_async
12
17
  from asgiref.sync import async_to_sync
13
- from django.test import Client, TransactionTestCase, TestCase, override_settings
18
+ from django.test import (
19
+ Client,
20
+ RequestFactory,
21
+ TransactionTestCase,
22
+ TestCase,
23
+ override_settings,
24
+ )
14
25
  from unittest import skip
15
26
  from contextlib import suppress
16
27
  from types import SimpleNamespace
17
- from unittest.mock import patch, Mock
28
+ from unittest.mock import patch, Mock, AsyncMock
18
29
  from django.contrib.auth import get_user_model
19
30
  from django.urls import reverse
20
31
  from django.utils import timezone
21
32
  from django.utils.dateparse import parse_datetime
22
33
  from django.utils.translation import override, gettext as _
23
34
  from django.contrib.sites.models import Site
35
+ from django.core.exceptions import ValidationError
24
36
  from pages.models import Application, Module
25
37
  from nodes.models import Node, NodeRole
26
38
 
27
39
  from config.asgi import application
28
40
 
29
- from .models import Transaction, Charger, Simulator, MeterReading, Location
41
+ from .models import (
42
+ Transaction,
43
+ Charger,
44
+ Simulator,
45
+ MeterReading,
46
+ Location,
47
+ DataTransferMessage,
48
+ )
30
49
  from .consumers import CSMSConsumer
31
- from core.models import EnergyAccount, EnergyCredit, Reference, RFID
50
+ from .views import dispatch_action
51
+ from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
32
52
  from . import store
33
53
  from django.db.models.deletion import ProtectedError
34
54
  from decimal import Decimal
@@ -87,6 +107,47 @@ class DummyWebSocket:
87
107
  self.sent.append(message)
88
108
 
89
109
 
110
+ class DispatchActionTests(TestCase):
111
+ def setUp(self):
112
+ self.factory = RequestFactory()
113
+
114
+ def tearDown(self): # pragma: no cover - cleanup guard
115
+ store.pending_calls.clear()
116
+ store.triggered_followups.clear()
117
+
118
+ def test_trigger_message_registers_pending_call(self):
119
+ charger = Charger.objects.create(charger_id="TRIGGER1")
120
+ dummy = DummyWebSocket()
121
+ key = store.set_connection("TRIGGER1", None, dummy)
122
+ self.addCleanup(lambda: store.connections.pop(key, None))
123
+ log_key = store.identity_key("TRIGGER1", None)
124
+ store.clear_log(log_key, log_type="charger")
125
+ self.addCleanup(lambda: store.clear_log(log_key, log_type="charger"))
126
+
127
+ request = self.factory.post(
128
+ "/chargers/TRIGGER1/action/",
129
+ data=json.dumps({"action": "trigger_message", "target": "BootNotification"}),
130
+ content_type="application/json",
131
+ )
132
+ request.user = SimpleNamespace(
133
+ is_authenticated=True,
134
+ is_superuser=True,
135
+ is_staff=True,
136
+ )
137
+
138
+ response = dispatch_action(request, "TRIGGER1")
139
+ self.assertEqual(response.status_code, 200)
140
+ self.assertTrue(dummy.sent)
141
+ frame = json.loads(dummy.sent[-1])
142
+ self.assertEqual(frame[0], 2)
143
+ self.assertEqual(frame[2], "TriggerMessage")
144
+ message_id = frame[1]
145
+ self.assertIn(message_id, store.pending_calls)
146
+ metadata = store.pending_calls[message_id]
147
+ self.assertEqual(metadata.get("action"), "TriggerMessage")
148
+ self.assertEqual(metadata.get("trigger_target"), "BootNotification")
149
+ self.assertEqual(metadata.get("log_key"), log_key)
150
+
90
151
  class ChargerFixtureTests(TestCase):
91
152
  fixtures = [
92
153
  p.name
@@ -315,6 +376,244 @@ class CSMSConsumerTests(TransactionTestCase):
315
376
  self.assertTrue(message_mock.save.called)
316
377
  self.assertTrue(message_mock.propagate.called)
317
378
 
379
+ async def test_change_availability_result_updates_model(self):
380
+ store.pending_calls.clear()
381
+ communicator = WebsocketCommunicator(application, "/AVAILRES/")
382
+ connected, _ = await communicator.connect()
383
+ self.assertTrue(connected)
384
+
385
+ await communicator.send_json_to(
386
+ [
387
+ 2,
388
+ "boot",
389
+ "BootNotification",
390
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
391
+ ]
392
+ )
393
+ await communicator.receive_json_from()
394
+
395
+ message_id = "ca-result"
396
+ requested_at = timezone.now()
397
+ store.register_pending_call(
398
+ message_id,
399
+ {
400
+ "action": "ChangeAvailability",
401
+ "charger_id": "AVAILRES",
402
+ "connector_id": None,
403
+ "availability_type": "Inoperative",
404
+ "requested_at": requested_at,
405
+ },
406
+ )
407
+ await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
408
+ await asyncio.sleep(0.05)
409
+
410
+ charger = await database_sync_to_async(Charger.objects.get)(
411
+ charger_id="AVAILRES", connector_id=None
412
+ )
413
+ self.assertEqual(charger.availability_state, "Inoperative")
414
+ self.assertEqual(charger.availability_request_status, "Accepted")
415
+ self.assertEqual(charger.availability_requested_state, "Inoperative")
416
+ await communicator.disconnect()
417
+
418
+ async def test_get_configuration_result_logged(self):
419
+ store.pending_calls.clear()
420
+ pending_key = store.pending_key("CFGRES")
421
+ store.clear_log(pending_key, log_type="charger")
422
+ log_key = store.identity_key("CFGRES", None)
423
+ store.clear_log(log_key, log_type="charger")
424
+ communicator = WebsocketCommunicator(application, "/CFGRES/")
425
+ connected, _ = await communicator.connect()
426
+ self.assertTrue(connected)
427
+
428
+ message_id = "cfg-result"
429
+ payload = {
430
+ "configurationKey": [
431
+ {
432
+ "key": "AllowOfflineTxForUnknownId",
433
+ "readonly": True,
434
+ "value": "false",
435
+ }
436
+ ]
437
+ }
438
+ store.register_pending_call(
439
+ message_id,
440
+ {
441
+ "action": "GetConfiguration",
442
+ "charger_id": "CFGRES",
443
+ "connector_id": None,
444
+ "log_key": log_key,
445
+ "requested_at": timezone.now(),
446
+ },
447
+ )
448
+
449
+ await communicator.send_json_to([3, message_id, payload])
450
+ await asyncio.sleep(0.05)
451
+
452
+ log_entries = store.get_logs(log_key, log_type="charger")
453
+ self.assertTrue(
454
+ any("GetConfiguration result" in entry for entry in log_entries)
455
+ )
456
+ self.assertNotIn(message_id, store.pending_calls)
457
+
458
+ await communicator.disconnect()
459
+ store.clear_log(log_key, log_type="charger")
460
+ store.clear_log(pending_key, log_type="charger")
461
+
462
+ async def test_get_configuration_error_logged(self):
463
+ store.pending_calls.clear()
464
+ pending_key = store.pending_key("CFGERR")
465
+ store.clear_log(pending_key, log_type="charger")
466
+ log_key = store.identity_key("CFGERR", None)
467
+ store.clear_log(log_key, log_type="charger")
468
+ communicator = WebsocketCommunicator(application, "/CFGERR/")
469
+ connected, _ = await communicator.connect()
470
+ self.assertTrue(connected)
471
+
472
+ message_id = "cfg-error"
473
+ store.register_pending_call(
474
+ message_id,
475
+ {
476
+ "action": "GetConfiguration",
477
+ "charger_id": "CFGERR",
478
+ "connector_id": None,
479
+ "log_key": log_key,
480
+ "requested_at": timezone.now(),
481
+ },
482
+ )
483
+
484
+ await communicator.send_json_to(
485
+ [4, message_id, "InternalError", "Boom", {"detail": "nope"}]
486
+ )
487
+ await asyncio.sleep(0.05)
488
+
489
+ log_entries = store.get_logs(log_key, log_type="charger")
490
+ self.assertTrue(
491
+ any("GetConfiguration error" in entry for entry in log_entries)
492
+ )
493
+ self.assertNotIn(message_id, store.pending_calls)
494
+
495
+ await communicator.disconnect()
496
+ store.clear_log(log_key, log_type="charger")
497
+ store.clear_log(pending_key, log_type="charger")
498
+
499
+ async def test_trigger_message_follow_up_logged(self):
500
+ store.pending_calls.clear()
501
+ cid = "TRIGLOG"
502
+ pending_key = store.pending_key(cid)
503
+ log_key = store.identity_key(cid, None)
504
+ store.clear_log(pending_key, log_type="charger")
505
+ store.clear_log(log_key, log_type="charger")
506
+
507
+ communicator = WebsocketCommunicator(application, f"/{cid}/")
508
+ connected, _ = await communicator.connect()
509
+ self.assertTrue(connected)
510
+
511
+ await communicator.send_json_to(
512
+ [
513
+ 2,
514
+ "boot",
515
+ "BootNotification",
516
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
517
+ ]
518
+ )
519
+ await communicator.receive_json_from()
520
+
521
+ message_id = "trigger-result"
522
+ store.register_pending_call(
523
+ message_id,
524
+ {
525
+ "action": "TriggerMessage",
526
+ "charger_id": cid,
527
+ "connector_id": None,
528
+ "log_key": log_key,
529
+ "trigger_target": "BootNotification",
530
+ "trigger_connector": None,
531
+ },
532
+ )
533
+
534
+ await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
535
+ await asyncio.sleep(0.05)
536
+ self.assertNotIn(message_id, store.pending_calls)
537
+
538
+ log_entries = store.get_logs(log_key, log_type="charger")
539
+ self.assertTrue(
540
+ any(
541
+ "TriggerMessage BootNotification result" in entry
542
+ or "TriggerMessage result" in entry
543
+ for entry in log_entries
544
+ )
545
+ )
546
+
547
+ await communicator.send_json_to(
548
+ [
549
+ 2,
550
+ "trigger-follow",
551
+ "BootNotification",
552
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
553
+ ]
554
+ )
555
+ await communicator.receive_json_from()
556
+ await asyncio.sleep(0.05)
557
+
558
+ log_entries = store.get_logs(log_key, log_type="charger")
559
+ self.assertTrue(
560
+ any(
561
+ "TriggerMessage follow-up received: BootNotification" in entry
562
+ for entry in log_entries
563
+ )
564
+ )
565
+
566
+ await communicator.disconnect()
567
+ store.clear_log(log_key, log_type="charger")
568
+ store.clear_log(pending_key, log_type="charger")
569
+
570
+ async def test_status_notification_updates_availability_state(self):
571
+ store.pending_calls.clear()
572
+ communicator = WebsocketCommunicator(application, "/STATAVAIL/")
573
+ connected, _ = await communicator.connect()
574
+ self.assertTrue(connected)
575
+
576
+ await communicator.send_json_to(
577
+ [
578
+ 2,
579
+ "boot",
580
+ "BootNotification",
581
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
582
+ ]
583
+ )
584
+ await communicator.receive_json_from()
585
+
586
+ await communicator.send_json_to(
587
+ [
588
+ 2,
589
+ "stat1",
590
+ "StatusNotification",
591
+ {"connectorId": 1, "errorCode": "NoError", "status": "Unavailable"},
592
+ ]
593
+ )
594
+ await communicator.receive_json_from()
595
+
596
+ charger = await database_sync_to_async(Charger.objects.get)(
597
+ charger_id="STATAVAIL", connector_id=1
598
+ )
599
+ self.assertEqual(charger.availability_state, "Inoperative")
600
+
601
+ await communicator.send_json_to(
602
+ [
603
+ 2,
604
+ "stat2",
605
+ "StatusNotification",
606
+ {"connectorId": 1, "errorCode": "NoError", "status": "Available"},
607
+ ]
608
+ )
609
+ await communicator.receive_json_from()
610
+
611
+ charger = await database_sync_to_async(Charger.objects.get)(
612
+ charger_id="STATAVAIL", connector_id=1
613
+ )
614
+ self.assertEqual(charger.availability_state, "Operative")
615
+ await communicator.disconnect()
616
+
318
617
  async def test_consumption_message_final_update_on_disconnect(self):
319
618
  await database_sync_to_async(Charger.objects.create)(charger_id="FINALMSG")
320
619
  communicator = WebsocketCommunicator(application, "/FINALMSG/")
@@ -1153,6 +1452,92 @@ class CSMSConsumerTests(TransactionTestCase):
1153
1452
  if connected3_retry and communicator3_retry is not None:
1154
1453
  await communicator3_retry.disconnect()
1155
1454
 
1455
+ async def test_data_transfer_inbound_persists_message(self):
1456
+ store.pending_calls.clear()
1457
+ communicator = WebsocketCommunicator(application, "/DTIN/")
1458
+ connected, _ = await communicator.connect()
1459
+ self.assertTrue(connected)
1460
+
1461
+ payload = {"vendorId": "Acme", "messageId": "diag", "data": {"foo": "bar"}}
1462
+ await communicator.send_json_to([2, "dt-msg", "DataTransfer", payload])
1463
+ response = await communicator.receive_json_from()
1464
+ self.assertEqual(response, [3, "dt-msg", {"status": "UnknownVendorId"}])
1465
+
1466
+ await communicator.disconnect()
1467
+
1468
+ message = await database_sync_to_async(DataTransferMessage.objects.get)(
1469
+ ocpp_message_id="dt-msg"
1470
+ )
1471
+ self.assertEqual(
1472
+ message.direction, DataTransferMessage.DIRECTION_CP_TO_CSMS
1473
+ )
1474
+ self.assertEqual(message.vendor_id, "Acme")
1475
+ self.assertEqual(message.message_id, "diag")
1476
+ self.assertEqual(message.payload, payload)
1477
+ self.assertEqual(message.status, "UnknownVendorId")
1478
+ self.assertIsNotNone(message.responded_at)
1479
+
1480
+ async def test_data_transfer_action_round_trip(self):
1481
+ store.pending_calls.clear()
1482
+ communicator = WebsocketCommunicator(application, "/DTOUT/")
1483
+ connected, _ = await communicator.connect()
1484
+ self.assertTrue(connected)
1485
+
1486
+ User = get_user_model()
1487
+ user = await database_sync_to_async(User.objects.create_user)(
1488
+ username="dtuser", password="pw"
1489
+ )
1490
+ await database_sync_to_async(self.client.force_login)(user)
1491
+
1492
+ url = reverse("charger-action", args=["DTOUT"])
1493
+ request_payload = {
1494
+ "action": "data_transfer",
1495
+ "vendorId": "AcmeCorp",
1496
+ "messageId": "ping",
1497
+ "data": {"echo": "value"},
1498
+ }
1499
+ response = await database_sync_to_async(self.client.post)(
1500
+ url,
1501
+ data=json.dumps(request_payload),
1502
+ content_type="application/json",
1503
+ )
1504
+ self.assertEqual(response.status_code, 200)
1505
+ response_body = json.loads(response.content.decode())
1506
+ sent_frame = json.loads(response_body["sent"])
1507
+ self.assertEqual(sent_frame[2], "DataTransfer")
1508
+ sent_payload = sent_frame[3]
1509
+ self.assertEqual(sent_payload["vendorId"], "AcmeCorp")
1510
+ self.assertEqual(sent_payload.get("messageId"), "ping")
1511
+
1512
+ outbound = await communicator.receive_json_from()
1513
+ self.assertEqual(outbound, sent_frame)
1514
+
1515
+ message_id = sent_frame[1]
1516
+ record = await database_sync_to_async(DataTransferMessage.objects.get)(
1517
+ ocpp_message_id=message_id
1518
+ )
1519
+ self.assertEqual(
1520
+ record.direction, DataTransferMessage.DIRECTION_CSMS_TO_CP
1521
+ )
1522
+ self.assertEqual(record.status, "Pending")
1523
+ self.assertIsNone(record.response_data)
1524
+ self.assertIn(message_id, store.pending_calls)
1525
+ self.assertEqual(store.pending_calls[message_id]["message_pk"], record.pk)
1526
+
1527
+ reply_payload = {"status": "Accepted", "data": {"result": "ok"}}
1528
+ await communicator.send_json_to([3, message_id, reply_payload])
1529
+ await asyncio.sleep(0.05)
1530
+
1531
+ updated = await database_sync_to_async(DataTransferMessage.objects.get)(
1532
+ pk=record.pk
1533
+ )
1534
+ self.assertEqual(updated.status, "Accepted")
1535
+ self.assertEqual(updated.response_data, {"result": "ok"})
1536
+ self.assertIsNotNone(updated.responded_at)
1537
+ self.assertNotIn(message_id, store.pending_calls)
1538
+
1539
+ await communicator.disconnect()
1540
+
1156
1541
 
1157
1542
  class ChargerLandingTests(TestCase):
1158
1543
  def setUp(self):
@@ -1185,6 +1570,21 @@ class ChargerLandingTests(TestCase):
1185
1570
  self.assertEqual(resp.status_code, 200)
1186
1571
  self.assertContains(resp, "PAGE2")
1187
1572
 
1573
+ def test_placeholder_serial_rejected(self):
1574
+ with self.assertRaises(ValidationError):
1575
+ Charger.objects.create(charger_id="<charger_id>")
1576
+
1577
+ def test_placeholder_serial_not_created_from_status_view(self):
1578
+ existing = Charger.objects.count()
1579
+ response = self.client.get(reverse("charger-status", args=["<charger_id>"]))
1580
+ self.assertEqual(response.status_code, 404)
1581
+ self.assertEqual(Charger.objects.count(), existing)
1582
+ self.assertFalse(
1583
+ Location.objects.filter(
1584
+ name__startswith="<", name__endswith=">", chargers__isnull=True
1585
+ ).exists()
1586
+ )
1587
+
1188
1588
  def test_charger_page_shows_progress(self):
1189
1589
  charger = Charger.objects.create(charger_id="STATS")
1190
1590
  tx = Transaction.objects.create(
@@ -1306,6 +1706,12 @@ class SimulatorLandingTests(TestCase):
1306
1706
  self.assertContains(resp, "/ocpp/")
1307
1707
  self.assertContains(resp, "/ocpp/simulator/")
1308
1708
 
1709
+ def test_cp_simulator_redirects_to_login(self):
1710
+ response = self.client.get(reverse("cp-simulator"))
1711
+ login_url = reverse("pages:login")
1712
+ self.assertEqual(response.status_code, 302)
1713
+ self.assertIn(login_url, response.url)
1714
+
1309
1715
 
1310
1716
  class ChargerAdminTests(TestCase):
1311
1717
  def setUp(self):
@@ -1374,6 +1780,35 @@ class ChargerAdminTests(TestCase):
1374
1780
  self.assertNotContains(resp, 'name="last_heartbeat"')
1375
1781
  self.assertNotContains(resp, 'name="last_meter_values"')
1376
1782
 
1783
+ def test_admin_action_sets_availability_state(self):
1784
+ charger = Charger.objects.create(charger_id="AVAIL1")
1785
+ url = reverse("admin:ocpp_charger_changelist")
1786
+ response = self.client.post(
1787
+ url,
1788
+ {
1789
+ "action": "set_availability_state_inoperative",
1790
+ "_selected_action": [charger.pk],
1791
+ },
1792
+ follow=True,
1793
+ )
1794
+ self.assertEqual(response.status_code, 200)
1795
+ charger.refresh_from_db()
1796
+ self.assertEqual(charger.availability_state, "Inoperative")
1797
+ self.assertIsNotNone(charger.availability_state_updated_at)
1798
+
1799
+ response = self.client.post(
1800
+ url,
1801
+ {
1802
+ "action": "set_availability_state_operative",
1803
+ "_selected_action": [charger.pk],
1804
+ },
1805
+ follow=True,
1806
+ )
1807
+ self.assertEqual(response.status_code, 200)
1808
+ charger.refresh_from_db()
1809
+ self.assertEqual(charger.availability_state, "Operative")
1810
+ self.assertIsNotNone(charger.availability_state_updated_at)
1811
+
1377
1812
  def test_purge_action_removes_data(self):
1378
1813
  charger = Charger.objects.create(charger_id="PURGE1")
1379
1814
  Transaction.objects.create(
@@ -1411,6 +1846,85 @@ class ChargerAdminTests(TestCase):
1411
1846
  self.client.post(delete_url, {"post": "yes"})
1412
1847
  self.assertFalse(Charger.objects.filter(pk=charger.pk).exists())
1413
1848
 
1849
+ def test_fetch_configuration_dispatches_request(self):
1850
+ charger = Charger.objects.create(charger_id="CFGADMIN", connector_id=1)
1851
+ ws = DummyWebSocket()
1852
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
1853
+ store.clear_log(log_key, log_type="charger")
1854
+ pending_key = store.pending_key(charger.charger_id)
1855
+ store.clear_log(pending_key, log_type="charger")
1856
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
1857
+ store.pending_calls.clear()
1858
+ try:
1859
+ url = reverse("admin:ocpp_charger_changelist")
1860
+ response = self.client.post(
1861
+ url,
1862
+ {
1863
+ "action": "fetch_cp_configuration",
1864
+ "_selected_action": [charger.pk],
1865
+ },
1866
+ follow=True,
1867
+ )
1868
+ self.assertEqual(response.status_code, 200)
1869
+ self.assertEqual(len(ws.sent), 1)
1870
+ frame = json.loads(ws.sent[0])
1871
+ self.assertEqual(frame[0], 2)
1872
+ self.assertEqual(frame[2], "GetConfiguration")
1873
+ self.assertIn(frame[1], store.pending_calls)
1874
+ metadata = store.pending_calls[frame[1]]
1875
+ self.assertEqual(metadata.get("action"), "GetConfiguration")
1876
+ self.assertEqual(metadata.get("charger_id"), charger.charger_id)
1877
+ self.assertEqual(metadata.get("connector_id"), charger.connector_id)
1878
+ self.assertEqual(metadata.get("log_key"), log_key)
1879
+ log_entries = store.get_logs(log_key, log_type="charger")
1880
+ self.assertTrue(
1881
+ any("GetConfiguration" in entry for entry in log_entries)
1882
+ )
1883
+ finally:
1884
+ store.pop_connection(charger.charger_id, charger.connector_id)
1885
+ store.pending_calls.clear()
1886
+ store.clear_log(log_key, log_type="charger")
1887
+ store.clear_log(pending_key, log_type="charger")
1888
+
1889
+ def test_fetch_configuration_timeout_logged(self):
1890
+ charger = Charger.objects.create(charger_id="CFGWAIT", connector_id=1)
1891
+ ws = DummyWebSocket()
1892
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
1893
+ pending_key = store.pending_key(charger.charger_id)
1894
+ store.clear_log(log_key, log_type="charger")
1895
+ store.clear_log(pending_key, log_type="charger")
1896
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
1897
+ store.pending_calls.clear()
1898
+ original_schedule = store.schedule_call_timeout
1899
+ try:
1900
+ with patch("ocpp.admin.store.schedule_call_timeout") as mock_schedule:
1901
+ def _side_effect(message_id, *, timeout=5.0, **kwargs):
1902
+ kwargs["timeout"] = 0.05
1903
+ return original_schedule(message_id, **kwargs)
1904
+
1905
+ mock_schedule.side_effect = _side_effect
1906
+ url = reverse("admin:ocpp_charger_changelist")
1907
+ response = self.client.post(
1908
+ url,
1909
+ {
1910
+ "action": "fetch_cp_configuration",
1911
+ "_selected_action": [charger.pk],
1912
+ },
1913
+ follow=True,
1914
+ )
1915
+ self.assertEqual(response.status_code, 200)
1916
+ mock_schedule.assert_called_once()
1917
+ time.sleep(0.1)
1918
+ log_entries = store.get_logs(log_key, log_type="charger")
1919
+ self.assertTrue(
1920
+ any("GetConfiguration timed out" in entry for entry in log_entries)
1921
+ )
1922
+ finally:
1923
+ store.pop_connection(charger.charger_id, charger.connector_id)
1924
+ store.pending_calls.clear()
1925
+ store.clear_log(log_key, log_type="charger")
1926
+ store.clear_log(pending_key, log_type="charger")
1927
+
1414
1928
 
1415
1929
  class LocationAdminTests(TestCase):
1416
1930
  def setUp(self):
@@ -1557,6 +2071,22 @@ class SimulatorAdminTests(TransactionTestCase):
1557
2071
  self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
1558
2072
  store.simulators.pop(sim.pk, None)
1559
2073
 
2074
+ @patch("ocpp.admin.asyncio.get_running_loop", side_effect=RuntimeError)
2075
+ def test_stop_simulator_runs_without_event_loop(self, mock_get_loop):
2076
+ sim = Simulator.objects.create(name="SIMSTOP", cp_path="SIMSTOP")
2077
+ stopper = SimpleNamespace(stop=AsyncMock())
2078
+ store.simulators[sim.pk] = stopper
2079
+ url = reverse("admin:ocpp_simulator_changelist")
2080
+ resp = self.client.post(
2081
+ url,
2082
+ {"action": "stop_simulator", "_selected_action": [sim.pk]},
2083
+ follow=True,
2084
+ )
2085
+ self.assertEqual(resp.status_code, 200)
2086
+ stopper.stop.assert_awaited_once()
2087
+ self.assertTrue(mock_get_loop.called)
2088
+ self.assertNotIn(sim.pk, store.simulators)
2089
+
1560
2090
  def test_as_config_includes_custom_fields(self):
1561
2091
  sim = Simulator.objects.create(
1562
2092
  name="SIM3",
@@ -1566,6 +2096,10 @@ class SimulatorAdminTests(TransactionTestCase):
1566
2096
  duration=500,
1567
2097
  pre_charge_delay=5,
1568
2098
  vin="WP0ZZZ99999999999",
2099
+ configuration_keys=[
2100
+ {"key": "HeartbeatInterval", "value": "300", "readonly": True}
2101
+ ],
2102
+ configuration_unknown_keys=["Bogus"],
1569
2103
  )
1570
2104
  cfg = sim.as_config()
1571
2105
  self.assertEqual(cfg.interval, 3.5)
@@ -1573,6 +2107,11 @@ class SimulatorAdminTests(TransactionTestCase):
1573
2107
  self.assertEqual(cfg.duration, 500)
1574
2108
  self.assertEqual(cfg.pre_charge_delay, 5)
1575
2109
  self.assertEqual(cfg.vin, "WP0ZZZ99999999999")
2110
+ self.assertEqual(
2111
+ cfg.configuration_keys,
2112
+ [{"key": "HeartbeatInterval", "value": "300", "readonly": True}],
2113
+ )
2114
+ self.assertEqual(cfg.configuration_unknown_keys, ["Bogus"])
1576
2115
 
1577
2116
  def _post_simulator_change(self, sim: Simulator, **overrides):
1578
2117
  url = reverse("admin:ocpp_simulator_change", args=[sim.pk])
@@ -1590,6 +2129,10 @@ class SimulatorAdminTests(TransactionTestCase):
1590
2129
  "username": sim.username,
1591
2130
  "password": sim.password,
1592
2131
  "door_open": "on" if overrides.get("door_open", False) else "",
2132
+ "configuration_keys": json.dumps(sim.configuration_keys or []),
2133
+ "configuration_unknown_keys": json.dumps(
2134
+ sim.configuration_unknown_keys or []
2135
+ ),
1593
2136
  "_save": "Save",
1594
2137
  }
1595
2138
  data.update(overrides)
@@ -2167,8 +2710,149 @@ class ChargePointSimulatorTests(TransactionTestCase):
2167
2710
  idx for idx, payload in enumerate(status_payloads) if payload.get("errorCode") == "NoError"
2168
2711
  )
2169
2712
  self.assertLess(first_open, first_close)
2170
- self.assertEqual(door_open_messages[0].get("status"), "Faulted")
2171
- self.assertEqual(door_closed_messages[0].get("status"), "Available")
2713
+
2714
+ async def test_get_configuration_uses_configured_keys(self):
2715
+ cfg = SimulatorConfig(
2716
+ configuration_keys=[
2717
+ {"key": "HeartbeatInterval", "value": "300", "readonly": True},
2718
+ {"key": "MeterValueSampleInterval", "value": 900},
2719
+ ],
2720
+ configuration_unknown_keys=["UnknownX"],
2721
+ )
2722
+ sim = ChargePointSimulator(cfg)
2723
+ sent: list[list[object]] = []
2724
+
2725
+ async def send(msg: str):
2726
+ sent.append(json.loads(msg))
2727
+
2728
+ async def recv(): # pragma: no cover - should not be called
2729
+ raise AssertionError("recv should not be called for GetConfiguration")
2730
+
2731
+ handled = await sim._handle_csms_call(
2732
+ [
2733
+ 2,
2734
+ "cfg-1",
2735
+ "GetConfiguration",
2736
+ {"key": ["HeartbeatInterval", "UnknownX", "MissingKey"]},
2737
+ ],
2738
+ send,
2739
+ recv,
2740
+ )
2741
+ self.assertTrue(handled)
2742
+ self.assertEqual(len(sent), 1)
2743
+ frame = sent[0]
2744
+ self.assertEqual(frame[0], 3)
2745
+ self.assertEqual(frame[1], "cfg-1")
2746
+ payload = frame[2]
2747
+ self.assertIn("configurationKey", payload)
2748
+ self.assertEqual(
2749
+ payload["configurationKey"],
2750
+ [{"key": "HeartbeatInterval", "value": "300", "readonly": True}],
2751
+ )
2752
+ self.assertIn("unknownKey", payload)
2753
+ self.assertCountEqual(payload["unknownKey"], ["UnknownX", "MissingKey"])
2754
+
2755
+ async def test_get_configuration_without_filter_returns_all(self):
2756
+ cfg = SimulatorConfig(
2757
+ configuration_keys=[
2758
+ {"key": "AuthorizeRemoteTxRequests", "value": True},
2759
+ {"key": "ConnectorPhaseRotation", "value": "ABC"},
2760
+ ],
2761
+ configuration_unknown_keys=["GhostKey"],
2762
+ )
2763
+ sim = ChargePointSimulator(cfg)
2764
+ sent: list[list[object]] = []
2765
+
2766
+ async def send(msg: str):
2767
+ sent.append(json.loads(msg))
2768
+
2769
+ async def recv(): # pragma: no cover - should not be called
2770
+ raise AssertionError("recv should not be called for GetConfiguration")
2771
+
2772
+ handled = await sim._handle_csms_call(
2773
+ [2, "cfg-2", "GetConfiguration", {}],
2774
+ send,
2775
+ recv,
2776
+ )
2777
+ self.assertTrue(handled)
2778
+ frame = sent[0]
2779
+ payload = frame[2]
2780
+ keys = payload.get("configurationKey")
2781
+ self.assertEqual(len(keys), 2)
2782
+ returned_keys = {item["key"] for item in keys}
2783
+ self.assertEqual(
2784
+ returned_keys,
2785
+ {"AuthorizeRemoteTxRequests", "ConnectorPhaseRotation"},
2786
+ )
2787
+ values = {item["key"]: item.get("value") for item in keys}
2788
+ self.assertEqual(values["AuthorizeRemoteTxRequests"], "True")
2789
+ self.assertEqual(values["ConnectorPhaseRotation"], "ABC")
2790
+ self.assertIn("unknownKey", payload)
2791
+ self.assertEqual(payload["unknownKey"], ["GhostKey"])
2792
+
2793
+ async def test_trigger_message_heartbeat_follow_up(self):
2794
+ cfg = SimulatorConfig()
2795
+ sim = ChargePointSimulator(cfg)
2796
+ sent: list[list[object]] = []
2797
+ recv_count = 0
2798
+
2799
+ async def send(msg: str):
2800
+ sent.append(json.loads(msg))
2801
+
2802
+ async def recv():
2803
+ nonlocal recv_count
2804
+ recv_count += 1
2805
+ return json.dumps([3, f"ack-{recv_count}", {}])
2806
+
2807
+ handled = await sim._handle_csms_call(
2808
+ [
2809
+ 2,
2810
+ "trigger-req",
2811
+ "TriggerMessage",
2812
+ {"requestedMessage": "Heartbeat"},
2813
+ ],
2814
+ send,
2815
+ recv,
2816
+ )
2817
+
2818
+ self.assertTrue(handled)
2819
+ self.assertGreaterEqual(len(sent), 2)
2820
+ result_frame = sent[0]
2821
+ follow_up_frame = sent[1]
2822
+ self.assertEqual(result_frame[0], 3)
2823
+ self.assertEqual(result_frame[1], "trigger-req")
2824
+ self.assertEqual(result_frame[2].get("status"), "Accepted")
2825
+ self.assertEqual(follow_up_frame[0], 2)
2826
+ self.assertEqual(follow_up_frame[2], "Heartbeat")
2827
+ self.assertEqual(recv_count, 1)
2828
+
2829
+ async def test_trigger_message_rejected_for_invalid_connector(self):
2830
+ cfg = SimulatorConfig(connector_id=5)
2831
+ sim = ChargePointSimulator(cfg)
2832
+ sent: list[list[object]] = []
2833
+
2834
+ async def send(msg: str):
2835
+ sent.append(json.loads(msg))
2836
+
2837
+ async def recv(): # pragma: no cover - should not be called
2838
+ raise AssertionError("recv should not be called for rejected TriggerMessage")
2839
+
2840
+ handled = await sim._handle_csms_call(
2841
+ [
2842
+ 2,
2843
+ "trigger-invalid",
2844
+ "TriggerMessage",
2845
+ {"requestedMessage": "StatusNotification", "connectorId": 1},
2846
+ ],
2847
+ send,
2848
+ recv,
2849
+ )
2850
+
2851
+ self.assertTrue(handled)
2852
+ self.assertEqual(len(sent), 1)
2853
+ self.assertEqual(sent[0][0], 3)
2854
+ self.assertEqual(sent[0][1], "trigger-invalid")
2855
+ self.assertEqual(sent[0][2].get("status"), "Rejected")
2172
2856
 
2173
2857
 
2174
2858
  class PurgeMeterReadingsTaskTests(TestCase):
@@ -2275,6 +2959,8 @@ class DispatchActionViewTests(TestCase):
2275
2959
  )
2276
2960
  store.clear_log(self.log_key, log_type="charger")
2277
2961
  self.addCleanup(store.clear_log, self.log_key, "charger")
2962
+ store.pending_calls.clear()
2963
+ self.addCleanup(store.pending_calls.clear)
2278
2964
  self.url = reverse(
2279
2965
  "charger-action-connector",
2280
2966
  args=[self.charger.charger_id, self.charger.connector_slug],
@@ -2321,6 +3007,37 @@ class DispatchActionViewTests(TestCase):
2321
3007
  any("RemoteStartTransaction" in entry for entry in log_entries)
2322
3008
  )
2323
3009
 
3010
+ def test_change_availability_dispatches_frame(self):
3011
+ response = self.client.post(
3012
+ self.url,
3013
+ data=json.dumps({"action": "change_availability", "type": "Inoperative"}),
3014
+ content_type="application/json",
3015
+ )
3016
+ self.assertEqual(response.status_code, 200)
3017
+ self.loop.run_until_complete(asyncio.sleep(0))
3018
+ self.assertEqual(len(self.ws.sent), 1)
3019
+ frame = json.loads(self.ws.sent[0])
3020
+ self.assertEqual(frame[0], 2)
3021
+ self.assertEqual(frame[2], "ChangeAvailability")
3022
+ self.assertEqual(frame[3]["type"], "Inoperative")
3023
+ self.assertEqual(frame[3]["connectorId"], 1)
3024
+ self.assertIn(frame[1], store.pending_calls)
3025
+ self.charger.refresh_from_db()
3026
+ self.assertEqual(self.charger.availability_requested_state, "Inoperative")
3027
+ self.assertIsNotNone(self.charger.availability_requested_at)
3028
+ self.assertEqual(self.charger.availability_request_status, "")
3029
+
3030
+ def test_change_availability_requires_valid_type(self):
3031
+ response = self.client.post(
3032
+ self.url,
3033
+ data=json.dumps({"action": "change_availability"}),
3034
+ content_type="application/json",
3035
+ )
3036
+ self.assertEqual(response.status_code, 400)
3037
+ self.loop.run_until_complete(asyncio.sleep(0))
3038
+ self.assertEqual(self.ws.sent, [])
3039
+ self.assertFalse(store.pending_calls)
3040
+
2324
3041
 
2325
3042
  class ChargerStatusViewTests(TestCase):
2326
3043
  def setUp(self):
@@ -2700,3 +3417,69 @@ class LiveUpdateViewTests(TestCase):
2700
3417
  ids = [item["charger_id"] for item in payload["chargers"]]
2701
3418
  self.assertIn(public.charger_id, ids)
2702
3419
  self.assertNotIn(private.charger_id, ids)
3420
+
3421
+ def test_dashboard_restricts_to_owner_users(self):
3422
+ User = get_user_model()
3423
+ owner = User.objects.create_user(username="owner", password="pw")
3424
+ other = User.objects.create_user(username="outsider", password="pw")
3425
+ unrestricted = Charger.objects.create(charger_id="UNRESTRICTED")
3426
+ restricted = Charger.objects.create(charger_id="RESTRICTED")
3427
+ restricted.owner_users.add(owner)
3428
+
3429
+ self.client.force_login(owner)
3430
+ owner_resp = self.client.get(reverse("ocpp-dashboard"))
3431
+ owner_chargers = [item["charger"] for item in owner_resp.context["chargers"]]
3432
+ self.assertIn(unrestricted, owner_chargers)
3433
+ self.assertIn(restricted, owner_chargers)
3434
+
3435
+ self.client.force_login(other)
3436
+ other_resp = self.client.get(reverse("ocpp-dashboard"))
3437
+ other_chargers = [item["charger"] for item in other_resp.context["chargers"]]
3438
+ self.assertIn(unrestricted, other_chargers)
3439
+ self.assertNotIn(restricted, other_chargers)
3440
+
3441
+ self.client.force_login(owner)
3442
+ detail_resp = self.client.get(
3443
+ reverse("charger-page", args=[restricted.charger_id])
3444
+ )
3445
+ self.assertEqual(detail_resp.status_code, 200)
3446
+
3447
+ self.client.force_login(other)
3448
+ denied_resp = self.client.get(
3449
+ reverse("charger-page", args=[restricted.charger_id])
3450
+ )
3451
+ self.assertEqual(denied_resp.status_code, 404)
3452
+
3453
+ def test_dashboard_restricts_to_owner_groups(self):
3454
+ User = get_user_model()
3455
+ group = SecurityGroup.objects.create(name="Operations")
3456
+ member = User.objects.create_user(username="member", password="pw")
3457
+ outsider = User.objects.create_user(username="visitor", password="pw")
3458
+ member.groups.add(group)
3459
+ unrestricted = Charger.objects.create(charger_id="GROUPFREE")
3460
+ restricted = Charger.objects.create(charger_id="GROUPLOCKED")
3461
+ restricted.owner_groups.add(group)
3462
+
3463
+ self.client.force_login(member)
3464
+ member_resp = self.client.get(reverse("ocpp-dashboard"))
3465
+ member_chargers = [item["charger"] for item in member_resp.context["chargers"]]
3466
+ self.assertIn(unrestricted, member_chargers)
3467
+ self.assertIn(restricted, member_chargers)
3468
+
3469
+ self.client.force_login(outsider)
3470
+ outsider_resp = self.client.get(reverse("ocpp-dashboard"))
3471
+ outsider_chargers = [item["charger"] for item in outsider_resp.context["chargers"]]
3472
+ self.assertIn(unrestricted, outsider_chargers)
3473
+ self.assertNotIn(restricted, outsider_chargers)
3474
+
3475
+ self.client.force_login(member)
3476
+ status_resp = self.client.get(
3477
+ reverse("charger-status", args=[restricted.charger_id])
3478
+ )
3479
+ self.assertEqual(status_resp.status_code, 200)
3480
+
3481
+ self.client.force_login(outsider)
3482
+ group_denied = self.client.get(
3483
+ reverse("charger-status", args=[restricted.charger_id])
3484
+ )
3485
+ self.assertEqual(group_denied.status_code, 404)