wrolpi/modules/flasher/test/test_flasher.py
lrnselfreliance bed25b429d
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection

Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection.  Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.

- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
  the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode.  `get_video_for_app` and
  `update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
  reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
  download list, retry, kill and the status summary, and docs/videos/archive statistics.

Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.

The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* downloads: startup retries in a write session of its own

start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran.  Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.

Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00

160 lines
7.6 KiB
Python

import pathlib
from http import HTTPStatus
import pytest
from modules.flasher.lib import read_esp_image_info, search_esp_firmware
@pytest.mark.parametrize('chip_id,kind,expected_chip,expected_kind', [
(0x0000, 'app', 'ESP32', 'app'),
(0x0002, 'app', 'ESP32-S2', 'app'),
(0x0009, 'app', 'ESP32-S3', 'app'),
(0x0005, 'app', 'ESP32-C3', 'app'),
(0x000D, 'app', 'ESP32-C6', 'app'),
(0x0000, 'factory_s2', 'ESP32', 'factory'), # ESP32/S2 layout: bootloader at 0x1000
(0x0009, 'factory', 'ESP32-S3', 'factory'), # S3 layout: bootloader at 0x0, partition table at 0x8000
(0x0000, 'bootloader', 'ESP32', 'bootloader'),
])
def test_read_esp_image_info(make_esp_image, chip_id, kind, expected_chip, expected_kind):
"""The header reader identifies the target chip and image kind from realistic header bytes."""
path = make_esp_image('software/fw.bin', chip_id=chip_id, kind=kind)
info = read_esp_image_info(path)
assert info['is_esp_image'] is True
assert info['chip'] == expected_chip
assert info['chip_id'] == chip_id
assert info['kind'] == expected_kind
def test_read_esp_image_info_not_esp(make_esp_image):
"""A .bin that is not an ESP image (no 0xE9 magic) is reported as such."""
path = make_esp_image('software/data.bin', kind='not_esp')
info = read_esp_image_info(path)
assert info == dict(is_esp_image=False, chip=None, chip_id=None, kind='not_esp_image')
def test_read_esp_image_info_unknown_chip(make_esp_image):
"""An ESP image with an unrecognized chip_id is still detected as an image, with chip=None."""
path = make_esp_image('software/fw.bin', chip_id=0x7777, kind='app')
info = read_esp_image_info(path)
assert info['is_esp_image'] is True
assert info['chip'] is None
assert info['chip_id'] == 0x7777
@pytest.mark.asyncio
async def test_search_esp_firmware_filters_by_chip(async_client, test_session, make_esp_image, refresh_files):
"""search_esp_firmware returns only ESP images matching the requested chip, annotated with chip/kind."""
make_esp_image('software/esp32-app.bin', chip_id=0x0000, kind='app')
make_esp_image('software/s2-app.bin', chip_id=0x0002, kind='app')
make_esp_image('software/s3-factory.bin', chip_id=0x0009, kind='factory')
make_esp_image('software/s3-app.bin', chip_id=0x0009, kind='app')
# A non-ESP .bin (e.g. a filesystem image or game installer) must never be returned.
make_esp_image('software/littlefs.bin', kind='not_esp')
await refresh_files()
def names(results):
# search_esp_firmware (lib layer) returns primary_path as an absolute Path; compare by basename.
return sorted(pathlib.Path(r['primary_path']).name for r in results)
# Filter to ESP32-S3: only the two S3 images, never the non-ESP file.
s3, total = search_esp_firmware(test_session, chip='ESP32-S3')
assert total == 2
assert names(s3) == ['s3-app.bin', 's3-factory.bin']
assert {r['esp_chip'] for r in s3} == {'ESP32-S3'}
assert {r['esp_kind'] for r in s3} == {'app', 'factory'}
# Filter to ESP32-S2: just the one.
s2, total = search_esp_firmware(test_session, chip='ESP32-S2')
assert total == 1
assert names(s2) == ['s2-app.bin']
# No chip: every .bin, including non-ESP parts (partition tables, littlefs, boot_app0) annotated with a null
# chip so a full flash set can still be assembled from the picker.
all_bin, total = search_esp_firmware(test_session)
assert total == 5
assert 'littlefs.bin' in names(all_bin)
littlefs = next(r for r in all_bin if pathlib.Path(r['primary_path']).name == 'littlefs.bin')
assert littlefs['esp_chip'] is None
assert littlefs['esp_kind'] == 'not_esp_image'
# Filtering to a specific chip still returns only that chip's ESP images (non-ESP parts are shown unfiltered).
_, total = search_esp_firmware(test_session, chip='ESP32-H2')
assert total == 0
@pytest.mark.asyncio
async def test_flasher_search_api(async_client, test_session, make_esp_image, refresh_files):
"""The /api/flasher/search endpoint filters by chip and supports the path filter."""
make_esp_image('software/marauder/esp32.bin', chip_id=0x0000, kind='app')
make_esp_image('software/marauder/s3.bin', chip_id=0x0009, kind='app')
make_esp_image('software/other/s3.bin', chip_id=0x0009, kind='app')
await refresh_files()
# Filter by chip.
request, response = await async_client.post('/api/flasher/search', json=dict(chip='ESP32-S3'))
assert response.status_code == HTTPStatus.OK
got = sorted(fg['primary_path'] for fg in response.json['file_groups'])
assert got == ['software/marauder/s3.bin', 'software/other/s3.bin']
assert response.json['totals']['file_groups'] == 2
# Filter by chip AND path (the flasher's detect + folder filter combined).
request, response = await async_client.post('/api/flasher/search',
json=dict(chip='ESP32-S3', path='marauder'))
assert response.status_code == HTTPStatus.OK
got = [fg['primary_path'] for fg in response.json['file_groups']]
assert got == ['software/marauder/s3.bin']
assert response.json['file_groups'][0]['esp_chip'] == 'ESP32-S3'
@pytest.mark.asyncio
async def test_flasher_saved_configs_api(async_client, test_session, test_directory):
"""Saved firmware configurations can be created, listed, replaced, deleted, and are persisted to YAML."""
# Initially empty.
request, response = await async_client.get('/api/flasher/configs')
assert response.status_code == HTTPStatus.OK
assert response.json['configurations'] == []
# Save a multi-part configuration (like a Meshtastic T-Deck).
body = dict(name='T-Deck MUI', erase_all=True, files=[
dict(path='software/firmware-t-deck-tft.bin', address='0x0', name='firmware-t-deck-tft.bin', size=100),
dict(path='software/littlefs-t-deck-tft.bin', address='0xc90000', name='littlefs-t-deck-tft.bin', size=200),
])
request, response = await async_client.post('/api/flasher/configs', json=body)
assert response.status_code == HTTPStatus.CREATED
# It is listed with its files and offsets.
request, response = await async_client.get('/api/flasher/configs')
configs = response.json['configurations']
assert len(configs) == 1
assert configs[0]['name'] == 'T-Deck MUI'
assert configs[0]['erase_all'] is True
assert [f['address'] for f in configs[0]['files']] == ['0x0', '0xc90000']
# It is persisted to flasher.yaml.
assert (test_directory / 'config/flasher.yaml').is_file()
# Saving the same name replaces it (still one configuration).
body2 = dict(name='T-Deck MUI', files=[dict(path='software/other.bin', address='0x0')])
request, response = await async_client.post('/api/flasher/configs', json=body2)
assert response.status_code == HTTPStatus.CREATED
request, response = await async_client.get('/api/flasher/configs')
configs = response.json['configurations']
assert len(configs) == 1
assert [f['path'] for f in configs[0]['files']] == ['software/other.bin']
# An empty name is rejected.
request, response = await async_client.post('/api/flasher/configs', json=dict(name=' ', files=[]))
assert response.status_code == HTTPStatus.BAD_REQUEST
# Delete it (name is URL-encoded).
request, response = await async_client.delete('/api/flasher/configs/T-Deck%20MUI')
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = await async_client.get('/api/flasher/configs')
assert response.json['configurations'] == []
# Deleting a missing configuration 404s.
request, response = await async_client.delete('/api/flasher/configs/nope')
assert response.status_code == HTTPStatus.NOT_FOUND