diff --git a/README.md b/README.md index e5ce95c..08d7b3e 100644 --- a/README.md +++ b/README.md @@ -203,11 +203,16 @@ Hardening done beyond the plan: only, never the payload), retained-message listing and clearing (audited as RETAINED_CLEARED); RBAC: viewing = operator+, publishing/clearing = broker-admin+ — verified live end-to-end -- [ ] Topic explorer: consolidated topic view (spec §35.1) — one row - per known topic; highlight differences between the new and the - previous message per topic (payload, QoS, retain, MQTT 5 - properties); briefly glow the row on new-message arrival; - browse the known history of each topic +- [x] Topic explorer: consolidated topic view (spec §35.1) — one row + per known topic (messages folded as they arrive); changed cells + (payload, QoS, retain, MQTT 5 properties) highlighted against the + previous message; changed tokens WITHIN a text payload highlighted + word-level (jsdiff diffWords — e.g. `{"cmd": "start"}` → + `{"cmd": "stop"}` marks only `stop`); fallbacks to plain text for + a topic's first message, binary payloads, and payloads >4 KB; row + glows briefly on new-message arrival; per-topic history browsable + (most recent first, per-topic ring buffer of 100 in the UI, + session buffer of 1000 server-side) - [ ] Connection tester + client provisioning wizard - [ ] Bridge configuration - [ ] Retained-message administration (partially covered by the topic diff --git a/backend/app/routers/topics.py b/backend/app/routers/topics.py index d878cf9..037a5fe 100644 --- a/backend/app/routers/topics.py +++ b/backend/app/routers/topics.py @@ -116,7 +116,10 @@ async def stream(session_id: str, user: OperatorRole): raise HTTPException(status.HTTP_404_NOT_FOUND, "session not found") from exc async def event_stream(): - index = 0 + # Start BELOW the first index: messages_since uses strictly- + # greater semantics, and a fresh session's first message has + # index 0 — a cursor starting at 0 would drop it. + index = -1 heartbeat = 0.0 try: while True: diff --git a/backend/pytest_output.txt b/backend/pytest_output.txt index a4088d7..e69de29 100644 --- a/backend/pytest_output.txt +++ b/backend/pytest_output.txt @@ -1,3854 +0,0 @@ -EEEEEEEEEEE..... [100%] -=================================== ERRORS ==================================== -_____________________ ERROR at setup of test_health_live ______________________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEB75DCD0> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -____________ ERROR at setup of test_login_sets_cookie_and_session _____________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEB75F490> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -_____________ ERROR at setup of test_login_wrong_password_audited _____________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEB73E450> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -_____________ ERROR at setup of test_unauthenticated_api_rejected _____________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBA88160> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -____________ ERROR at setup of test_auditor_cannot_create_clients _____________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBA8A8B0> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -_________ ERROR at setup of test_create_client_returns_password_once __________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBDDDE50> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -_____________ ERROR at setup of test_disable_enable_delete_client _____________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBDDFE50> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -__________________ ERROR at setup of test_role_acl_lifecycle __________________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBDC8F50> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -___________________ ERROR at setup of test_group_lifecycle ____________________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBB48C80> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -___________ ERROR at setup of test_listener_management_and_preview ____________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBDCE6D0> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -________________ ERROR at setup of test_audit_events_recorded _________________ - -self = -dialect = -context = -statement = -parameters = [('SUPER_ADMIN',)] - - def _exec_single_context( - self, - dialect: Dialect, - context: ExecutionContext, - statement: Union[str, Compiled], - parameters: Optional[_AnyMultiExecuteParams], - ) -> CursorResult[Any]: - """continue the _execute_context() method for a single DBAPI - cursor.execute() or cursor.executemany() call. - - """ - if dialect.bind_typing is BindTyping.SETINPUTSIZES: - generic_setinputsizes = context._prepare_set_input_sizes() - - if generic_setinputsizes: - try: - dialect.do_set_input_sizes( - context.cursor, generic_setinputsizes, context - ) - except BaseException as e: - self._handle_dbapi_exception( - e, str(statement), parameters, None, context - ) - - cursor, str_statement, parameters = ( - context.cursor, - context.statement, - context.parameters, - ) - - effective_parameters: Optional[_AnyExecuteParams] - - if not context.executemany: - effective_parameters = parameters[0] - else: - effective_parameters = parameters - - if self._has_events or self.engine._has_events: - for fn in self.dispatch.before_cursor_execute: - str_statement, effective_parameters = fn( - self, - cursor, - str_statement, - effective_parameters, - context, - context.executemany, - ) - - if self._echo: - self._log_info(str_statement) - - stats = context._get_cache_stats() - - if not self.engine.hide_parameters: - self._log_info( - "[%s] %r", - stats, - sql_util._repr_params( - effective_parameters, - batches=10, - ismulti=context.executemany, - ), - ) - else: - self._log_info( - "[%s] [SQL parameters hidden due to hide_parameters=True]", - stats, - ) - - evt_handled: bool = False - try: - if context.execute_style is ExecuteStyle.EXECUTEMANY: - effective_parameters = cast( - "_CoreMultiExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_executemany: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: - self.dialect.do_executemany( - cursor, - str_statement, - effective_parameters, - context, - ) - elif not effective_parameters and context.no_parameters: - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute_no_params: - if fn(cursor, str_statement, context): - evt_handled = True - break - if not evt_handled: - self.dialect.do_execute_no_params( - cursor, str_statement, context - ) - else: - effective_parameters = cast( - "_CoreSingleExecuteParams", effective_parameters - ) - if self.dialect._has_events: - for fn in self.dialect.dispatch.do_execute: - if fn( - cursor, - str_statement, - effective_parameters, - context, - ): - evt_handled = True - break - if not evt_handled: -> self.dialect.do_execute( - cursor, str_statement, effective_parameters, context - ) - -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlite3.OperationalError: no such table: users - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError - -The above exception was the direct cause of the following exception: - -db_session = -monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBBC2DD0> - - @pytest.fixture() - def client(db_session, monkeypatch): - """TestClient wired to the fixture DB; dynsec calls are faked out.""" - # Point the app's session factory at the test engine. - monkeypatch.setattr(db_module, "_SessionFactory", None) - monkeypatch.setattr( - db_module, "_engine", db_session.get_bind() - ) - - # Fake dynsec so no broker is needed for API tests. - class FakeDynsec: - def __init__(self): - self.clients: dict[str, dict] = {} - self.roles: dict[str, dict] = {} - self.groups: dict[str, dict] = {} - - def _command(self, command, **kwargs): - return {"success": True} - - def create_client(self, username, password, **kwargs): - self.clients[username] = {"username": username, "enabled": True, **kwargs} - - def list_clients(self): - return list(self.clients.values()) - - def delete_client(self, username): - self.clients.pop(username, None) - - def disable_client(self, username): - self.clients[username]["enabled"] = False - - def enable_client(self, username): - self.clients[username]["enabled"] = True - - def set_client_password(self, username, password): - pass - - def get_client(self, username): - return self.clients[username] - - def create_group(self, groupname, roles=None): - self.groups[groupname] = {"groupname": groupname, "roles": roles or []} - - def list_groups(self): - return list(self.groups.values()) - - def delete_group(self, groupname): - self.groups.pop(groupname, None) - - def create_role(self, rolename, acls=None): - self.roles[rolename] = {"rolename": rolename, "acls": acls or []} - - def list_roles(self): - return list(self.roles.values()) - - def delete_role(self, rolename): - self.roles.pop(rolename, None) - - def set_role_acls(self, rolename, acls): - self.roles[rolename]["acls"] = acls - - def get_role(self, rolename): - return self.roles[rolename] - - def connect(self): - pass - - def close(self): - pass - - fake = FakeDynsec() - monkeypatch.setattr(main_module, "dynsec_client", fake) - - from app.routers import mqtt as mqtt_router - - monkeypatch.setattr(mqtt_router, "dynsec_client", fake) - -> with TestClient(main_module.app) as test_client: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -tests\conftest.py:132: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ - portal.call(self.wait_startup) -.venv\Lib\site-packages\anyio\from_thread.py:340: in call - return cast(T_Retval, self.start_task_soon(func, *args).result()) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup - await receive() -.venv\Lib\site-packages\starlette\testclient.py:724: in receive - self.task.result() -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result - return self.__get_result() - ^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result - raise self._exception -.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func - retval = await retval_or_awaitable - ^^^^^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan - await self.app(scope, self.stream_receive.receive, self.stream_send.send) -.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ - await super().__call__(scope, receive, send) -.venv\Lib\site-packages\starlette\applications.py:96: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ - await self.app(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:670: in __call__ - await self.middleware_stack(scope, receive, send) -.venv\Lib\site-packages\fastapi\routing.py:2726: in app - await self.lifespan(scope, receive, send) -.venv\Lib\site-packages\starlette\routing.py:648: in lifespan - async with self.lifespan_context(app) as maybe_state: - ^^^^^^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan - async with original_context(app) as maybe_original_state: - ^^^^^^^^^^^^^^^^^^^^^ -..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ - return await anext(self.gen) - ^^^^^^^^^^^^^^^^^^^^^ -app\main.py:65: in lifespan - _bootstrap_admin() -app\main.py:34: in _bootstrap_admin - has_admin = session.scalar( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar - return self._execute_internal( -.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( -.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement - result = conn.execute( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute - return meth( -.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection - return connection._execute_clauseelement( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement - ret = self._execute_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context - return self._exec_single_context( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context - self._handle_dbapi_exception( -.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e -.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context - self.dialect.do_execute( -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -cursor = -statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' -parameters = ('SUPER_ADMIN',) -context = - - def do_execute(self, cursor, statement, parameters, context=None): -> cursor.execute(statement, parameters) -E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users -E [SQL: SELECT users.id -E FROM users -E WHERE users.role = ?] -E [parameters: ('SUPER_ADMIN',)] -E (Background on this error at: https://sqlalche.me/e/20/e3q8) - -.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError -============================== warnings summary =============================== -.venv\Lib\site-packages\fastapi\testclient.py:1 - C:\Users\jonas\Documents\Git\mqtt-broker-stack\backend\.venv\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. - from starlette.testclient import TestClient as TestClient # noqa - -.venv\Lib\site-packages\starlette\testclient.py:53 - C:\Users\jonas\Documents\Git\mqtt-broker-stack\backend\.venv\Lib\site-packages\starlette\testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead. - _PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]] - --- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html -=========================== short test summary info =========================== -ERROR tests/test_api.py::test_health_live - sqlalchemy.exc.OperationalError: ... -ERROR tests/test_api.py::test_login_sets_cookie_and_session - sqlalchemy.exc.... -ERROR tests/test_api.py::test_login_wrong_password_audited - sqlalchemy.exc.O... -ERROR tests/test_api.py::test_unauthenticated_api_rejected - sqlalchemy.exc.O... -ERROR tests/test_api.py::test_auditor_cannot_create_clients - sqlalchemy.exc.... -ERROR tests/test_api.py::test_create_client_returns_password_once - sqlalchem... -ERROR tests/test_api.py::test_disable_enable_delete_client - sqlalchemy.exc.O... -ERROR tests/test_api.py::test_role_acl_lifecycle - sqlalchemy.exc.Operational... -ERROR tests/test_api.py::test_group_lifecycle - sqlalchemy.exc.OperationalErr... -ERROR tests/test_api.py::test_listener_management_and_preview - sqlalchemy.ex... -ERROR tests/test_api.py::test_audit_events_recorded - sqlalchemy.exc.Operatio... -5 passed, 2 warnings, 11 errors in 11.71s - diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a32f3a6..b0ffaac 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -256,6 +256,7 @@ def client(db_session, monkeypatch): self.id = id self.username = username self.topic_filter = topic_filter + self.buffer = [] # list of message dicts, index-tagged class FakeSessions: def __init__(self): @@ -281,7 +282,8 @@ def client(db_session, monkeypatch): monkeypatch.setattr(explorer_module, "sessions", fake_sessions) def fake_messages_since(session_id, index): - return [] + session = fake_sessions.get(session_id) + return [dict(m) for m in session.buffer if m["index"] > index] def fake_list_retained(max_results=200, timeout=3.0): return [ diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index ca0fcf2..da74823 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -283,6 +283,58 @@ def test_topic_explorer_flow(admin_client): assert any(m["topic"] == "site/config/level" for m in retained.json()) +def test_topic_explorer_stream_delivers_first_message(admin_client, monkeypatch): + """Regression test: the SSE cursor must include a session's FIRST + buffered message (index 0). messages_since is strictly-greater, so + a cursor starting at 0 dropped it — subscribe, publish {"cmd":"start"} + then {"cmd":"stop"}, and the stream must deliver BOTH.""" + from app import explorer as explorer_module + + sub = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "e2e/#"}) + assert sub.status_code == 200 + session_id = sub.json()["session_id"] + + session = explorer_module.sessions.get(session_id) + # Simulate the broker delivering both payloads (as _on_message would). + session.buffer = [ + { + "index": 0, + "topic": "e2e/diff/device1", + "qos": 1, + "retain": False, + "received_at": 1.0, + "payload_b64": "eyJjbWQiOiAic3RhcnQifQ==", + "payload_text": '{"cmd": "start"}', + "properties": None, + }, + { + "index": 1, + "topic": "e2e/diff/device1", + "qos": 1, + "retain": False, + "received_at": 2.0, + "payload_b64": "eyJjbWQiOiAic3RvcCJ9", + "payload_text": '{"cmd": "stop"}', + "properties": None, + }, + ] + + with admin_client.stream("GET", f"/api/v1/topics/stream/{session_id}") as response: + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + received = [] + for line in response.iter_lines(): + if line.startswith("data:"): + received.append(json.loads(line[len("data:"):].strip())) + if len(received) == 2: + break + + assert [m["payload_text"] for m in received] == [ + '{"cmd": "start"}', + '{"cmd": "stop"}', + ], "stream must deliver the session's first message (index 0), not drop it" + + def test_topic_explorer_publish_audited(admin_client, db_session): """Publishes through the test console are audited (spec section 35), and wildcards are rejected.""" diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..81d95c6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1003 @@ +{ + "name": "mqtt-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mqtt-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "diff": "^7.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@types/diff": "^5.2.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.103.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.1", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/diff": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz", + "integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.431", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.56", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 866891b..2ee68e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "@tanstack/react-query": "^5.62.0", + "diff": "^7.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.0" }, "devDependencies": { + "@types/diff": "^5.2.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/frontend/src/pages/TopicExplorer.tsx b/frontend/src/pages/TopicExplorer.tsx index 2dfc553..bbfcd64 100644 --- a/frontend/src/pages/TopicExplorer.tsx +++ b/frontend/src/pages/TopicExplorer.tsx @@ -1,8 +1,83 @@ import { FormEvent, useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { diffWords } from "diff"; import { api, type ExplorerMessage, type RetainedMessage } from "../api"; -const MAX_MESSAGES = 500; // UI-side cap; the backend buffers 1000 +const HISTORY_PER_TOPIC = 100; // history kept per topic in the UI + +// --- Consolidated topic view (spec 35.1) ------------------------------------ + +interface TopicRow { + topic: string; + latest: ExplorerMessage; + previous: ExplorerMessage | null; + history: ExplorerMessage[]; // most recent first + updated_at: number; // for the glow animation key +} + +/** Which fields changed between the previous and the latest message. */ +function diffFields( + latest: ExplorerMessage, + previous: ExplorerMessage | null, +): Set { + if (previous === null) return new Set(["payload", "qos", "retain", "properties"]); + const changed = new Set(); + if (latest.payload_b64 !== previous.payload_b64) changed.add("payload"); + if (latest.qos !== previous.qos) changed.add("qos"); + if (latest.retain !== previous.retain) changed.add("retain"); + if (JSON.stringify(latest.properties ?? null) !== JSON.stringify(previous.properties ?? null)) + changed.add("properties"); + return changed; +} + +/** Fold a live message into the per-topic map (spec 35.1: one row per topic). */ +function foldMessage( + rows: Map, + msg: ExplorerMessage, +): Map { + const existing = rows.get(msg.topic); + if (existing) { + existing.previous = existing.latest; + existing.latest = msg; + existing.history = [msg, ...existing.history].slice(0, HISTORY_PER_TOPIC); + existing.updated_at = msg.received_at; + } else { + rows.set(msg.topic, { + topic: msg.topic, + latest: msg, + previous: null, + history: [msg], + updated_at: msg.received_at, + }); + } + return rows; +} + +/** Cell that highlights when its field changed vs the previous message. */ +function DiffCell({ + changed, + children, + className, + style, +}: { + changed: boolean; + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + return ( + + {children} + + ); +} function toHex(b64: string): string { const bytes = atob(b64); @@ -11,6 +86,59 @@ function toHex(b64: string): string { .join(" "); } +// Word-level payload highlighting (spec 35.1): only the tokens that +// CHANGED between the previous and the latest payload are marked — +// e.g. {"cmd": "start"} -> {"cmd": "stop"} highlights just `stop`. +const DIFF_MAX_BYTES = 4096; // diffing huge payloads on every render is not worth it + +function PayloadDiff({ + latest, + previous, +}: { + latest: ExplorerMessage; + previous: ExplorerMessage | null; +}) { + const text = latest.payload_text; + const prev = previous?.payload_text ?? null; + // Fallbacks: first message for the topic, either side binary, or + // oversized payloads render plain (the amber cell background from + // diffFields still marks the change at field level). + if ( + text === null || + prev === null || + atob(latest.payload_b64).length > DIFF_MAX_BYTES || + atob(previous!.payload_b64).length > DIFF_MAX_BYTES + ) { + return <>{text ?? `(binary, ${atob(latest.payload_b64).length} B)`}; + } + try { + const parts = diffWords(prev, text); + return ( + <> + {parts.map((part, i) => + part.added ? ( + + {part.value} + + ) : part.removed ? null : ( + {part.value} + ), + )} + + ); + } catch { + // diff can throw on pathological inputs — never break rendering. + return <>{text}; + } +} + function PayloadView({ msg }: { msg: ExplorerMessage | RetainedMessage }) { const [mode, setMode] = useState<"text" | "hex">( msg.payload_text !== null ? "text" : "hex", @@ -38,7 +166,12 @@ function PayloadView({ msg }: { msg: ExplorerMessage | RetainedMessage }) { export function TopicExplorerPage() { const queryClient = useQueryClient(); const [filter, setFilter] = useState(""); - const [messages, setMessages] = useState([]); + // Consolidated view (spec 35.1): one entry per KNOWN TOPIC. The flat + // message stream is folded into this map as messages arrive. + const [topicRows, setTopicRows] = useState>(new Map()); + // Topics that just received a message — drives the brief glow. + const [glowing, setGlowing] = useState>(new Set()); + const [selectedTopic, setSelectedTopic] = useState(null); const [selected, setSelected] = useState(null); const [streamError, setStreamError] = useState(null); const [streaming, setStreaming] = useState(false); @@ -65,7 +198,8 @@ export function TopicExplorerPage() { const startStream = async (topicFilter: string) => { stopStream(); - setMessages([]); + setTopicRows(new Map()); + setSelectedTopic(null); setSelected(null); setStreamError(null); try { @@ -77,10 +211,24 @@ export function TopicExplorerPage() { es.onopen = () => setStreaming(true); es.onmessage = (event) => { const msg: ExplorerMessage = JSON.parse(event.data); - setMessages((prev) => { - const next = [...prev, msg]; - return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next; + // Fold into the per-topic map (spec 35.1: one row per topic). + setTopicRows((prev) => { + const next = new Map(prev); + return foldMessage(next, msg); }); + // Brief glow for this topic, cleared after the animation. + setGlowing((prev) => { + const next = new Set(prev); + next.add(msg.topic); + return next; + }); + window.setTimeout(() => { + setGlowing((prev) => { + const next = new Set(prev); + next.delete(msg.topic); + return next; + }); + }, 1200); }; es.onerror = () => { setStreamError( @@ -171,38 +319,65 @@ export function TopicExplorerPage() { -

Messages

+

Topics

+

+ One row per known topic (spec §35.1). Highlighted cells changed vs the + previous message; the row glows briefly on new-message arrival. Click + a topic to browse its known history. +

- + - + - {messages.map((m) => ( - setSelected(m)} - style={{ cursor: "pointer" }} - > - - - - - - - ))} + {Array.from(topicRows.values()) + .sort((a, b) => b.updated_at - a.updated_at) + .map((row) => { + const changed = diffFields(row.latest, row.previous); + return ( + { + setSelectedTopic( + selectedTopic === row.topic ? null : row.topic, + ); + setSelected(null); + }} + style={{ cursor: "pointer" }} + className={glowing.has(row.topic) ? "row-glow" : undefined} + > + + + {new Date(row.latest.received_at * 1000).toLocaleTimeString()} + + {row.latest.qos} + + {row.latest.retain ? "yes" : "—"} + + + + + + ); + })}
Time TopicLast update QoS RetainPayloadLatest payload
- {new Date(m.received_at * 1000).toLocaleTimeString()} - {m.topic}{m.qos}{m.retain ? "yes" : "—"} - {m.payload_text ?? `(binary, ${atob(m.payload_b64).length} B)`} -
{row.topic}
- {messages.length === 0 && !streamError && ( + {topicRows.size === 0 && !streamError && (

{streaming ? "Waiting for messages matching the filter…" @@ -210,6 +385,54 @@ export function TopicExplorerPage() {

)} + {selectedTopic && topicRows.get(selectedTopic) && ( +
+

+ History of {selectedTopic}{" "} + (most recent first) +

+ + + + + + + + + + + {topicRows.get(selectedTopic)!.history.map((m) => ( + setSelected(m)} + style={{ cursor: "pointer" }} + > + + + + + + ))} + +
TimeQoSRetainPayload
+ {new Date(m.received_at * 1000).toLocaleTimeString()} + {m.qos}{m.retain ? "yes" : "—"} + {m.payload_text ?? `(binary, ${atob(m.payload_b64).length} B)`} +
+ {topicRows.get(selectedTopic)!.history.length === 0 && ( +

No messages known for this topic.

+ )} +
+ )} + {selected && (

diff --git a/frontend/src/styles.css b/frontend/src/styles.css index eaf77db..e46c29f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -163,6 +163,25 @@ h3[id] { scroll-margin-top: 1rem; } +/* --- Consolidated topic view (spec 35.1) ---------------------------------- */ +/* Brief glow on a topic row when a new message arrives. Applied for + ~1.2s via JS (topic removed from the `glowing` set), so the animation + runs once per message and the row settles back to normal. */ +@keyframes topic-row-glow { + 0% { + background: #ffe9a8; + box-shadow: 0 0 8px rgba(255, 200, 80, 0.7); + } + 100% { + background: transparent; + box-shadow: none; + } +} + +tr.row-glow { + animation: topic-row-glow 1.2s ease-out; +} + /* --- Restart-needed indicator (sidebar, below the header) ------------- */ .restart-needed { display: block; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..59f5160 --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/api.ts","./src/auth.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/pages/BrokerConfig.tsx","./src/pages/Certificates.tsx","./src/pages/Clients.tsx","./src/pages/Groups.tsx","./src/pages/Login.tsx","./src/pages/Roles.tsx","./src/pages/Status.tsx","./src/pages/System.tsx","./src/pages/TopicExplorer.tsx"],"version":"5.7.3"} \ No newline at end of file