This commit is contained in:
jokob-sk
2026-02-11 15:33:52 +11:00
44 changed files with 777 additions and 189 deletions

View File

@@ -8,7 +8,7 @@ INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
from utils.datetime_utils import timeNowDB # noqa: E402 [flake8 lint suppression]
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
from api_server.api_server_start import app # noqa: E402 [flake8 lint suppression]
@@ -43,7 +43,7 @@ def b64(sql: str) -> str:
# -----------------------------
def test_dbquery_create_device(client, api_token, test_mac):
now = timeNowDB()
now = timeNowUTC()
sql = f"""
INSERT INTO Devices (devMac, devName, devVendor, devOwner, devFirstConnection, devLastConnection, devLastIP)

View File

@@ -8,7 +8,7 @@ INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
from utils.datetime_utils import timeNowTZ # noqa: E402 [flake8 lint suppression]
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
from api_server.api_server_start import app # noqa: E402 [flake8 lint suppression]
@@ -38,7 +38,7 @@ def create_event(client, api_token, mac, event="UnitTest Event", days_old=None):
# Calculate the event_time if days_old is given
if days_old is not None:
event_time = timeNowTZ() - timedelta(days=days_old)
event_time = timeNowUTC(as_string=False) - timedelta(days=days_old)
# ISO 8601 string
payload["event_time"] = event_time.isoformat()
@@ -140,7 +140,7 @@ def test_delete_events_dynamic_days(client, api_token, test_mac):
# Count pre-existing events younger than 30 days for test_mac
# These will remain after delete operation
from datetime import datetime
thirty_days_ago = timeNowTZ() - timedelta(days=30)
thirty_days_ago = timeNowUTC(as_string=False) - timedelta(days=30)
initial_younger_count = 0
for ev in initial_events:
if ev.get("eve_MAC") == test_mac and ev.get("eve_DateTime"):

View File

@@ -8,7 +8,7 @@ INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
from utils.datetime_utils import timeNowTZ, timeNowDB # noqa: E402 [flake8 lint suppression]
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
from api_server.api_server_start import app # noqa: E402 [flake8 lint suppression]
@@ -50,7 +50,7 @@ def test_create_session(client, api_token, test_mac):
payload = {
"mac": test_mac,
"ip": "192.168.1.100",
"start_time": timeNowDB(),
"start_time": timeNowUTC(),
"event_type_conn": "Connected",
"event_type_disc": "Disconnected"
}
@@ -65,7 +65,7 @@ def test_list_sessions(client, api_token, test_mac):
payload = {
"mac": test_mac,
"ip": "192.168.1.100",
"start_time": timeNowDB()
"start_time": timeNowUTC()
}
client.post("/sessions/create", json=payload, headers=auth_headers(api_token))
@@ -82,7 +82,7 @@ def test_device_sessions_by_period(client, api_token, test_mac):
payload = {
"mac": test_mac,
"ip": "192.168.1.200",
"start_time": timeNowDB()
"start_time": timeNowUTC()
}
resp_create = client.post("/sessions/create", json=payload, headers=auth_headers(api_token))
assert resp_create.status_code == 200
@@ -117,7 +117,7 @@ def test_device_session_events(client, api_token, test_mac):
payload = {
"mac": test_mac,
"ip": "192.168.1.250",
"start_time": timeNowDB()
"start_time": timeNowUTC()
}
resp_create = client.post(
"/sessions/create",
@@ -166,7 +166,7 @@ def test_delete_session(client, api_token, test_mac):
payload = {
"mac": test_mac,
"ip": "192.168.1.100",
"start_time": timeNowDB()
"start_time": timeNowUTC()
}
client.post("/sessions/create", json=payload, headers=auth_headers(api_token))
@@ -188,7 +188,7 @@ def test_get_sessions_calendar(client, api_token, test_mac):
Cleans up test sessions after test.
"""
# --- Setup: create two sessions for the test MAC ---
now = timeNowTZ()
now = timeNowUTC(as_string=False)
start1 = (now - timedelta(days=2)).isoformat(timespec="seconds")
end1 = (now - timedelta(days=1, hours=20)).isoformat(timespec="seconds")

View File

@@ -0,0 +1,238 @@
"""
Unit tests for database timestamp migration to UTC.
Tests verify that:
- Migration detects version correctly from Settings table
- Fresh installs skip migration (empty VERSION)
- Upgrades from v26.2.6+ skip migration (already UTC)
- Upgrades from <v26.2.6 run migration (convert local→UTC)
- Migration handles timezone offset calculations correctly
- Migration is idempotent (safe to run multiple times)
"""
import sys
import os
import pytest
import sqlite3
import tempfile
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from db.db_upgrade import migrate_timestamps_to_utc, is_timestamps_in_utc # noqa: E402
from utils.datetime_utils import timeNowUTC # noqa: E402
@pytest.fixture
def temp_db():
"""Create a temporary database for testing"""
fd, db_path = tempfile.mkstemp(suffix='.db')
os.close(fd)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Create Settings table
cursor.execute("""
CREATE TABLE Settings (
setKey TEXT PRIMARY KEY,
setValue TEXT
)
""")
# Create Devices table with timestamp columns
cursor.execute("""
CREATE TABLE Devices (
devMac TEXT PRIMARY KEY,
devFirstConnection TEXT,
devLastConnection TEXT,
devLastNotification TEXT
)
""")
conn.commit()
yield cursor, conn
conn.close()
os.unlink(db_path)
class TestTimestampMigration:
"""Test suite for UTC timestamp migration"""
def test_migrate_fresh_install_skips_migration(self, temp_db):
"""Test that fresh install with empty VERSION skips migration"""
cursor, conn = temp_db
# Empty Settings table (fresh install)
result = migrate_timestamps_to_utc(cursor)
assert result is True
# Should return without error
def test_migrate_unknown_version_skips_migration(self, temp_db):
"""Test that 'unknown' VERSION skips migration"""
cursor, conn = temp_db
# Insert 'unknown' VERSION
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', 'unknown')")
conn.commit()
result = migrate_timestamps_to_utc(cursor)
assert result is True
def test_migrate_version_26_2_6_skips_migration(self, temp_db):
"""Test that v26.2.6 skips migration (already UTC)"""
cursor, conn = temp_db
# Insert VERSION v26.2.6
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', '26.2.6')")
conn.commit()
result = migrate_timestamps_to_utc(cursor)
assert result is True
def test_migrate_version_27_0_0_skips_migration(self, temp_db):
"""Test that v27.0.0 skips migration (newer version)"""
cursor, conn = temp_db
# Insert VERSION v27.0.0
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', '27.0.0')")
conn.commit()
result = migrate_timestamps_to_utc(cursor)
assert result is True
def test_migrate_version_26_3_0_skips_migration(self, temp_db):
"""Test that v26.3.0 skips migration (newer minor version)"""
cursor, conn = temp_db
# Insert VERSION v26.3.0
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', '26.3.0')")
conn.commit()
result = migrate_timestamps_to_utc(cursor)
assert result is True
def test_migrate_old_version_triggers_migration(self, temp_db):
"""Test that v25.x.x triggers migration"""
cursor, conn = temp_db
# Insert VERSION v25.1.0
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', '25.1.0')")
# Insert a sample device with timestamp
now_str = timeNowUTC()
cursor.execute("""
INSERT INTO Devices (devMac, devFirstConnection, devLastConnection)
VALUES ('aa:bb:cc:dd:ee:ff', ?, ?)
""", (now_str, now_str))
conn.commit()
result = migrate_timestamps_to_utc(cursor)
assert result is True
def test_migrate_version_with_v_prefix(self, temp_db):
"""Test that version string with 'v' prefix is parsed correctly"""
cursor, conn = temp_db
# Insert VERSION with 'v' prefix
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', 'v26.2.6')")
conn.commit()
result = migrate_timestamps_to_utc(cursor)
assert result is True
def test_migrate_malformed_version_uses_fallback(self, temp_db):
"""Test that malformed version string uses timestamp detection fallback"""
cursor, conn = temp_db
# Insert malformed VERSION
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', 'invalid.version')")
conn.commit()
result = migrate_timestamps_to_utc(cursor)
# Should not crash, should use fallback detection
assert result is True
def test_migrate_version_26_2_5_triggers_migration(self, temp_db):
"""Test that v26.2.5 (one patch before UTC) triggers migration"""
cursor, conn = temp_db
# Insert VERSION v26.2.5
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', '26.2.5')")
# Insert sample device
now_str = timeNowUTC()
cursor.execute("""
INSERT INTO Devices (devMac, devFirstConnection)
VALUES ('aa:bb:cc:dd:ee:ff', ?)
""", (now_str,))
conn.commit()
result = migrate_timestamps_to_utc(cursor)
assert result is True
def test_migrate_does_not_crash_on_empty_devices_table(self, temp_db):
"""Test that migration handles empty Devices table gracefully"""
cursor, conn = temp_db
# Insert old VERSION but no devices
cursor.execute("INSERT INTO Settings (setKey, setValue) VALUES ('VERSION', '25.1.0')")
conn.commit()
result = migrate_timestamps_to_utc(cursor)
assert result is True
def test_is_timestamps_in_utc_returns_true_for_empty_table(self, temp_db):
"""Test that is_timestamps_in_utc returns True for empty Devices table"""
cursor, conn = temp_db
result = is_timestamps_in_utc(cursor)
assert result is True
def test_is_timestamps_in_utc_detects_utc_timestamps(self, temp_db):
"""Test that is_timestamps_in_utc correctly identifies UTC timestamps"""
cursor, conn = temp_db
# Insert devices with UTC timestamps
now_str = timeNowUTC()
cursor.execute("""
INSERT INTO Devices (devMac, devFirstConnection)
VALUES ('aa:bb:cc:dd:ee:ff', ?)
""", (now_str,))
conn.commit()
result = is_timestamps_in_utc(cursor)
# Should return False for naive timestamps (no timezone marker)
# This is expected behavior - naive timestamps need migration check
assert result is False
def test_is_timestamps_in_utc_detects_timezone_markers(self, temp_db):
"""Test that is_timestamps_in_utc detects timestamps with timezone info"""
cursor, conn = temp_db
# Insert device with timezone marker
timestamp_with_tz = "2026-02-11 11:37:02+00:00"
cursor.execute("""
INSERT INTO Devices (devMac, devFirstConnection)
VALUES ('aa:bb:cc:dd:ee:ff', ?)
""", (timestamp_with_tz,))
conn.commit()
result = is_timestamps_in_utc(cursor)
# Should detect timezone marker
assert result is True

View File

@@ -0,0 +1,106 @@
"""
Unit tests for datetime_utils.py UTC timestamp functions.
Tests verify that:
- timeNowUTC() returns correct formats (string and datetime object)
- All timestamps are in UTC timezone
- No other functions call datetime.datetime.now() (single source of truth)
"""
import sys
import os
import datetime
import pytest
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from utils.datetime_utils import timeNowUTC, DATETIME_PATTERN # noqa: E402
class TestTimeNowUTC:
"""Test suite for timeNowUTC() function"""
def test_timeNowUTC_returns_string_by_default(self):
"""Test that timeNowUTC() returns a string by default"""
result = timeNowUTC()
assert isinstance(result, str)
assert len(result) == 19 # 'YYYY-MM-DD HH:MM:SS' format
def test_timeNowUTC_string_format(self):
"""Test that timeNowUTC() returns correct string format"""
result = timeNowUTC()
# Verify format matches DATETIME_PATTERN
try:
datetime.datetime.strptime(result, DATETIME_PATTERN)
except ValueError:
pytest.fail(f"timeNowUTC() returned invalid format: {result}")
def test_timeNowUTC_returns_datetime_object_when_false(self):
"""Test that timeNowUTC(as_string=False) returns datetime object"""
result = timeNowUTC(as_string=False)
assert isinstance(result, datetime.datetime)
def test_timeNowUTC_datetime_has_UTC_timezone(self):
"""Test that datetime object has UTC timezone"""
result = timeNowUTC(as_string=False)
assert result.tzinfo is datetime.UTC
def test_timeNowUTC_datetime_no_microseconds(self):
"""Test that datetime object has microseconds set to 0"""
result = timeNowUTC(as_string=False)
assert result.microsecond == 0
def test_timeNowUTC_consistency_between_modes(self):
"""Test that string and datetime modes return consistent values"""
dt_obj = timeNowUTC(as_string=False)
str_result = timeNowUTC(as_string=True)
# Convert datetime to string and compare (within 1 second tolerance)
dt_str = dt_obj.strftime(DATETIME_PATTERN)
# Parse both to compare timestamps
t1 = datetime.datetime.strptime(dt_str, DATETIME_PATTERN)
t2 = datetime.datetime.strptime(str_result, DATETIME_PATTERN)
diff = abs((t1 - t2).total_seconds())
assert diff <= 1 # Allow 1 second difference
def test_timeNowUTC_is_actually_UTC(self):
"""Test that timeNowUTC() returns actual UTC time, not local time"""
utc_now = datetime.datetime.now(datetime.UTC).replace(microsecond=0)
result = timeNowUTC(as_string=False)
# Should be within 1 second
diff = abs((utc_now - result).total_seconds())
assert diff <= 1
def test_timeNowUTC_string_matches_datetime_conversion(self):
"""Test that string result matches datetime object conversion"""
dt_obj = timeNowUTC(as_string=False)
str_result = timeNowUTC(as_string=True)
# Convert datetime to string using same format
expected = dt_obj.strftime(DATETIME_PATTERN)
# Should be same or within 1 second
t1 = datetime.datetime.strptime(expected, DATETIME_PATTERN)
t2 = datetime.datetime.strptime(str_result, DATETIME_PATTERN)
diff = abs((t1 - t2).total_seconds())
assert diff <= 1
def test_timeNowUTC_explicit_true_parameter(self):
"""Test that timeNowUTC(as_string=True) explicitly returns string"""
result = timeNowUTC(as_string=True)
assert isinstance(result, str)
def test_timeNowUTC_multiple_calls_increase(self):
"""Test that subsequent calls return increasing timestamps"""
import time
t1_str = timeNowUTC()
time.sleep(0.1)
t2_str = timeNowUTC()
t1 = datetime.datetime.strptime(t1_str, DATETIME_PATTERN)
t2 = datetime.datetime.strptime(t2_str, DATETIME_PATTERN)
assert t2 >= t1