import sqlite3 import pytest from app import db, ids from app.services import devices def test_ids_are_unique_typed_and_ordered(): """Все ID глобально уникальны: не повторяются, типы не пересекаются, внутри типа растут со временем.""" seen, last = set(), {} for i in range(50_000): prefix = list(ids.PREFIXES)[i % len(ids.PREFIXES)] v = ids.new_id(prefix) assert v not in seen and v > last.get(prefix, "") assert ids.is_id(v, prefix) and not any(ids.is_id(v, o) for o in ids.PREFIXES if o != prefix) seen.add(v) last[prefix] = v with pytest.raises(LookupError): # ID другого типа в пути/запросе отклоняется ids.check(ids.new_id("job"), "dev") with pytest.raises(LookupError): devices.get_device("1") # числовые ID больше не существуют LEGACY_DDL = """ CREATE TABLE devices (id INTEGER NOT NULL, name VARCHAR(64) NOT NULL, host VARCHAR(255) NOT NULL, port INTEGER NOT NULL, username VARCHAR(64) NOT NULL, password_enc TEXT NOT NULL, verify_tls BOOLEAN NOT NULL, created_at DATETIME NOT NULL, online BOOLEAN, status_json TEXT NOT NULL, status_at DATETIME, last_error TEXT, last_backup_requested_at DATETIME, use_tls BOOLEAN NOT NULL DEFAULT 1, group_id INTEGER, note TEXT, PRIMARY KEY (id), UNIQUE (name)); CREATE TABLE jobs (id INTEGER NOT NULL, device_id INTEGER, device_name VARCHAR(64) NOT NULL, type VARCHAR(32) NOT NULL, status VARCHAR(16) NOT NULL, message TEXT NOT NULL, created_at DATETIME NOT NULL, finished_at DATETIME, PRIMARY KEY (id)); CREATE TABLE backups (id INTEGER NOT NULL, device_id INTEGER NOT NULL, requested_at DATETIME NOT NULL, status VARCHAR(16) NOT NULL, key_binary VARCHAR(512), key_rsc VARCHAR(512), error TEXT, PRIMARY KEY (id), FOREIGN KEY(device_id) REFERENCES devices (id) ON DELETE CASCADE); CREATE TABLE device_groups (id INTEGER NOT NULL, name VARCHAR(64) NOT NULL, PRIMARY KEY (id), UNIQUE (name)); """ def test_migration_replaces_numeric_ids(tmp_path): """БД старой схемы: числовые ID заменяются на dev_/grp_/job_/bkp_, ссылки и порядок сохраняются, повтор — без эффекта.""" path = tmp_path / "legacy.db" con = sqlite3.connect(path) con.executescript(LEGACY_DDL) con.executemany("INSERT INTO device_groups VALUES (?, ?)", [(1, "Alpha"), (2, "Beta")]) for i, (name, grp) in enumerate((("r1", 1), ("r2", None)), start=1): con.execute("INSERT INTO devices (id, name, host, port, username, password_enc, verify_tls, created_at, status_json, use_tls, group_id)" " VALUES (?,?,?,?,?,?,?,?,?,?,?)", (i, name, "10.0.0.%d" % i, 80, "u", "enc", 0, "2026-09-1%d 10:00:00.000000" % i, "{}", 0, grp)) # задачи: две по r1 (id 1), одна по уже удалённому устройству (id 99) for i, dev in enumerate((1, 1, 99), start=1): con.execute("INSERT INTO jobs VALUES (?,?,?,?,?,?,?,?)", (i, dev, "n", "backup", "done", "ok", "2026-09-19 10:0%d:00.000000" % i, None)) con.execute("INSERT INTO backups VALUES (1, 1, '2026-09-19 10:00:00.000000', 'done', 'backups/r1/a.backup', 'backups/r1/a.rsc', NULL)") con.execute("INSERT INTO backups VALUES (2, 99, '2026-09-19 10:05:00.000000', 'done', 'backups/gone/b.backup', NULL, NULL)") con.commit() con.close() db.init_db(f"sqlite:///{path}") con = sqlite3.connect(path) assert con.execute("PRAGMA user_version").fetchone()[0] == 2 counts = {t: con.execute(f"SELECT count(*) FROM {t}").fetchone()[0] for t in ("device_groups", "devices", "jobs", "backups")} assert counts == {"device_groups": 2, "devices": 2, "jobs": 3, "backups": 2} alpha = con.execute("SELECT id FROM device_groups WHERE name='Alpha'").fetchone()[0] r1, r2 = (con.execute("SELECT id, group_id FROM devices WHERE name=?", (n,)).fetchone() for n in ("r1", "r2")) assert ids.is_id(r1[0], "dev") and r1[1] == alpha and r2[1] is None # связь устройство → группа сохранена job_dev = [r[0] for r in con.execute("SELECT device_id FROM jobs ORDER BY id")] assert job_dev[0] == job_dev[1] == r1[0] and job_dev[2] not in (r1[0], r2[0]) # удалённое устройство — свой стабильный ID assert [r[0] for r in con.execute("SELECT id FROM jobs ORDER BY created_at")] == [r[0] for r in con.execute("SELECT id FROM jobs ORDER BY id")] bk = con.execute("SELECT device_id, device_name FROM backups ORDER BY requested_at").fetchall() assert bk[0] == (r1[0], "r1") and bk[1][0] == job_dev[2] and bk[1][1] == "gone" # имя — из ключа S3 every = [r[0] for t in ("device_groups", "devices", "jobs", "backups", "events") for r in con.execute(f"SELECT id FROM {t}")] assert len(every) == len(set(every)) and all(ids.is_id(i) for i in every) # ни одного пересечения между таблицами assert con.execute("SELECT type FROM events").fetchall() == [("system.migrated",)] assert len(list(tmp_path.glob("legacy.db.bak-*"))) == 1 # копия БД перед миграцией con.close() db.init_db(f"sqlite:///{path}") # повторный запуск ничего не меняет con = sqlite3.connect(path) assert con.execute("SELECT count(*) FROM events").fetchone()[0] == 1 and len(list(tmp_path.glob("legacy.db.bak-*"))) == 1 con.close() LEGACY_DDL_NO_EXTRA_COLUMNS = """ CREATE TABLE devices (id INTEGER NOT NULL, name VARCHAR(64) NOT NULL, host VARCHAR(255) NOT NULL, port INTEGER NOT NULL, username VARCHAR(64) NOT NULL, password_enc TEXT NOT NULL, verify_tls BOOLEAN NOT NULL, created_at DATETIME NOT NULL, online BOOLEAN, status_json TEXT NOT NULL, status_at DATETIME, last_error TEXT, last_backup_requested_at DATETIME, PRIMARY KEY (id), UNIQUE (name)); CREATE TABLE jobs (id INTEGER NOT NULL, device_id INTEGER, device_name VARCHAR(64) NOT NULL, type VARCHAR(32) NOT NULL, status VARCHAR(16) NOT NULL, message TEXT NOT NULL, created_at DATETIME NOT NULL, finished_at DATETIME, PRIMARY KEY (id)); CREATE TABLE backups (id INTEGER NOT NULL, device_id INTEGER NOT NULL, requested_at DATETIME NOT NULL, status VARCHAR(16) NOT NULL, key_binary VARCHAR(512), key_rsc VARCHAR(512), error TEXT, PRIMARY KEY (id), FOREIGN KEY(device_id) REFERENCES devices (id) ON DELETE CASCADE); CREATE TABLE device_groups (id INTEGER NOT NULL, name VARCHAR(64) NOT NULL, PRIMARY KEY (id), UNIQUE (name)); """ def test_migration_adds_legacy_columns_before_id_migration(tmp_path): """Схема до появления групп/TLS-настроек/примечаний (без use_tls/group_id/note): колонки добавляются раньше миграции ID — _to_v1 читает их из старой таблицы.""" path = tmp_path / "legacy2.db" con = sqlite3.connect(path) con.executescript(LEGACY_DDL_NO_EXTRA_COLUMNS) con.execute("INSERT INTO devices (id, name, host, port, username, password_enc, verify_tls, created_at, status_json)" " VALUES (1, 'r1', '10.0.0.1', 80, 'u', 'enc', 0, '2026-09-10 10:00:00.000000', '{}')") con.commit() con.close() db.init_db(f"sqlite:///{path}") con = sqlite3.connect(path) assert con.execute("PRAGMA user_version").fetchone()[0] == 2 dev_id, use_tls, group_id, note = con.execute("SELECT id, use_tls, group_id, note FROM devices").fetchone() assert ids.is_id(dev_id, "dev") and (use_tls, group_id, note) == (1, None, None) # значения по умолчанию con.close()