Compare commits
No commits in common. "main" and "v0.3.3" have entirely different histories.
24 changed files with 1056 additions and 2124 deletions
|
|
@ -2,7 +2,7 @@ files: src|tests
|
|||
exclude: ^$
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
args: [ --markdown-linebreak-ext=md ]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.13
|
||||
3.11
|
||||
|
|
|
|||
19
CHANGELOG
19
CHANGELOG
|
|
@ -2,24 +2,7 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.3.6] - 2025-06-06
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add `Quote.is_active` field
|
||||
|
||||
## [0.3.5] - 2025-05-27
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Implement remaining repo methods for chatbot and quote
|
||||
- Add ANO_PREFIX bot response
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Add renovate lockFileMaintenance
|
||||
|
||||
## [0.3.3] - 2025-03-06
|
||||
## [unreleased]
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ ENV PYTHONPATH="$APP_PATH"
|
|||
ENV PATH="$APP_HOME/.local/bin:$PATH"
|
||||
|
||||
# hadolint ignore=DL3001,DL3008,DL3018
|
||||
RUN apk add --no-cache make python3~=3.13 curl git \
|
||||
RUN apk add --no-cache make python3~=3.12 curl \
|
||||
&& adduser -S -u "$USERID" -h "$APP_HOME" "$USERNAME" \
|
||||
&& mkdir -p "$APP_PATH" \
|
||||
&& chown -R "$USERID:$GROUPID" "$APP_PATH"
|
||||
|
|
|
|||
3
Makefile
3
Makefile
|
|
@ -11,9 +11,6 @@ fmt--mypy:
|
|||
fmt--add-noqa:
|
||||
uvx ruff check --add-noqa .
|
||||
|
||||
fmt--autoupdate:
|
||||
uvx pre-commit autoupdate
|
||||
|
||||
|
||||
.PHONY: tests
|
||||
tests:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
apiVersion: v2
|
||||
appVersion: 0.3.7
|
||||
appVersion: 0.3.3
|
||||
description: A Helm chart for Kubernetes
|
||||
name: huesoporro
|
||||
type: application
|
||||
version: 0.3.7
|
||||
version: 0.3.3
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ fullnameOverride: ''
|
|||
image:
|
||||
pullPolicy: Always
|
||||
repository: git.roboces.dev/catalin/huesoporro
|
||||
tag: 0.3.7
|
||||
tag: 0.3.3
|
||||
imagePullSecrets: []
|
||||
ingress:
|
||||
annotations: {}
|
||||
|
|
|
|||
55
devenv.lock
55
devenv.lock
|
|
@ -19,10 +19,10 @@
|
|||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"lastModified": 1733328505,
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -46,31 +46,10 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1747372754,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
|
|
@ -104,7 +83,7 @@
|
|||
},
|
||||
"nixpkgs-python": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat_2",
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
|
|
@ -122,15 +101,33 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat_2",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1737465171,
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-python": "nixpkgs-python",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
"""
|
||||
This module contains a Caribou migration.
|
||||
|
||||
Migration Name: active_quotes
|
||||
Migration Version: 20250606143836
|
||||
"""
|
||||
|
||||
|
||||
def upgrade(connection):
|
||||
# add `is_active` column to the `quotes` table
|
||||
sql = """
|
||||
ALTER TABLE quotes
|
||||
ADD COLUMN is_active BOOLEAN DEFAULT TRUE;
|
||||
"""
|
||||
connection.execute(sql)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def downgrade(connection):
|
||||
# add your downgrade step here
|
||||
pass
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "huesoporro"
|
||||
version = "0.3.7"
|
||||
version = "0.3.3"
|
||||
description = "Misc Twitch bot"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
|
|
@ -19,13 +19,11 @@ dependencies = [
|
|||
"caribou>=0.4.1",
|
||||
"aiosqlite>=0.20.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"twitchio==2.10.0",
|
||||
"twitchio>=2.10.0",
|
||||
"redis>=5.2.1",
|
||||
"pytz>=2024.2",
|
||||
"discord-py>=2.4.0",
|
||||
"tenacity>=9.0.0",
|
||||
"uvicorn>=0.34.0",
|
||||
"sniffio>=1.3.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||
lockFileMaintenance: {
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ app = Typer()
|
|||
|
||||
|
||||
@app.command()
|
||||
def import_vod(channel_name: str, youtube_url: str, db_path: Path | None = None):
|
||||
def import_vod_cc(channel_name: str, youtube_url: str, db_path: Path | None = None):
|
||||
logger.info(f"Importing VOD closed captions for {channel_name} from {youtube_url}")
|
||||
s = Settings.get(db_filepath=db_path)
|
||||
import_from_vod_action = ImportFromVODAction(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from huesoporro.actions.chatbot.create_or_update_chatbot import (
|
|||
from huesoporro.actions.chatbot.get_chatbot_by_user_id import GetChatbotByUserIdAction
|
||||
from huesoporro.actions.users.authenticate_user import AuthenticateUserAction
|
||||
from huesoporro.actions.users.get_user_by_jwt import GetUserByJWTAction
|
||||
from huesoporro.bot import BotsManager
|
||||
from huesoporro.infra.authenticator import TwitchAuthenticator
|
||||
from huesoporro.infra.repos import ChatbotRepo, UserRepo
|
||||
from huesoporro.libs.db import MarkovDatabase
|
||||
|
|
@ -29,29 +28,29 @@ from huesoporro.svc.users_svcs import (
|
|||
)
|
||||
|
||||
|
||||
async def get_settings() -> Settings:
|
||||
def get_settings() -> Settings:
|
||||
return Settings.get()
|
||||
|
||||
|
||||
async def get_authenticator(s: Settings) -> TwitchAuthenticator:
|
||||
def get_authenticator(s: Settings) -> TwitchAuthenticator:
|
||||
return TwitchAuthenticator(s=s)
|
||||
|
||||
|
||||
async def get_chatbot_repo(s: Settings):
|
||||
def get_chatbot_repo(s: Settings):
|
||||
return ChatbotRepo(s=s)
|
||||
|
||||
|
||||
async def get_get_chatbot_by_user_id_svc(chatbot_repo: ChatbotRepo):
|
||||
def get_get_chatbot_by_user_id_svc(chatbot_repo: ChatbotRepo):
|
||||
return GetChatbotByUserIdSvc(repo=chatbot_repo)
|
||||
|
||||
|
||||
async def get_get_tokens_by_auth_code_svc(
|
||||
def get_get_tokens_by_auth_code_svc(
|
||||
twitch_authenticator: TwitchAuthenticator, s: Settings
|
||||
):
|
||||
return GetTwitchAuthByAuthCodeSvc(s=s, authenticator=twitch_authenticator)
|
||||
|
||||
|
||||
async def get_create_chatbot_svc(chatbot_repo: ChatbotRepo):
|
||||
def get_create_chatbot_svc(chatbot_repo: ChatbotRepo):
|
||||
return CreateChatbotSvc(repo=chatbot_repo)
|
||||
|
||||
|
||||
|
|
@ -59,19 +58,19 @@ async def get_user_repo(s: Settings):
|
|||
return UserRepo(s=s)
|
||||
|
||||
|
||||
async def get_create_user_svc(user_repo: UserRepo):
|
||||
def get_create_user_svc(user_repo: UserRepo):
|
||||
return CreateUserSvc(user_repo=user_repo)
|
||||
|
||||
|
||||
async def get_update_user_svc(user_repo: UserRepo):
|
||||
def get_update_user_svc(user_repo: UserRepo):
|
||||
return UpdateUserSvc(user_repo=user_repo)
|
||||
|
||||
|
||||
async def get_refresh_token_svc(twitch_authenticator: TwitchAuthenticator):
|
||||
def get_refresh_token_svc(twitch_authenticator: TwitchAuthenticator):
|
||||
return RefreshTokenSvc(twitch_authenticator=twitch_authenticator)
|
||||
|
||||
|
||||
async def get_is_valid_token_svc(twitch_authenticator: TwitchAuthenticator):
|
||||
def get_is_valid_token_svc(twitch_authenticator: TwitchAuthenticator):
|
||||
return IsValidTokenSvc(authenticator=twitch_authenticator)
|
||||
|
||||
|
||||
|
|
@ -119,11 +118,11 @@ async def get_sentences_storer_svc(db: MarkovDatabase):
|
|||
return SentenceStorerSvc(db=db)
|
||||
|
||||
|
||||
async def get_update_chatbot_svc(chatbot_repo: ChatbotRepo):
|
||||
def get_update_chatbot_svc(chatbot_repo: ChatbotRepo):
|
||||
return UpdateChatbotSvc(repo=chatbot_repo)
|
||||
|
||||
|
||||
async def get_create_or_update_chatbot_action(
|
||||
def get_create_or_update_chatbot_action(
|
||||
create_chatbot_svc: CreateChatbotSvc,
|
||||
update_chatbot_svc: UpdateChatbotSvc,
|
||||
get_chatbot_by_user_id_svc: GetChatbotByUserIdSvc,
|
||||
|
|
@ -135,7 +134,7 @@ async def get_create_or_update_chatbot_action(
|
|||
)
|
||||
|
||||
|
||||
async def get_get_chatbot_by_user_id_action(
|
||||
def get_get_chatbot_by_user_id_action(
|
||||
get_chatbot_by_user_id_svc: GetChatbotByUserIdSvc,
|
||||
):
|
||||
return GetChatbotByUserIdAction(
|
||||
|
|
@ -159,10 +158,6 @@ async def get_authenticate_action(
|
|||
)
|
||||
|
||||
|
||||
async def get_bot_manager(s: Settings):
|
||||
return BotsManager(s=s)
|
||||
|
||||
|
||||
async def chatbot(
|
||||
get_chatbot_by_user_id_action: GetChatbotByUserIdAction,
|
||||
create_or_update_chatbot_action: CreateOrUpdateChatbotAction,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from apps.httpapi.litestar.dependencies import (
|
|||
authenticate,
|
||||
get_authenticate_action,
|
||||
get_authenticator,
|
||||
get_bot_manager,
|
||||
get_chatbot_repo,
|
||||
get_create_chatbot_svc,
|
||||
get_create_or_update_chatbot_action,
|
||||
|
|
@ -44,11 +43,12 @@ from apps.httpapi.litestar.routes.api import (
|
|||
save_bot_settings,
|
||||
)
|
||||
from apps.httpapi.litestar.routes.auth import get_code, login
|
||||
from huesoporro.bot import BotsManager
|
||||
from huesoporro.settings import Settings
|
||||
|
||||
|
||||
@get("/healthz")
|
||||
async def get_health() -> dict:
|
||||
def get_health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ def create_app():
|
|||
"s": Provide(get_settings, use_cache=True),
|
||||
"a": Provide(get_authenticator, use_cache=True),
|
||||
"user": Provide(authenticate),
|
||||
"bm": Provide(get_bot_manager, use_cache=True),
|
||||
"bm": Provide(BotsManager, use_cache=True),
|
||||
"sss": Provide(get_sentences_storer_svc),
|
||||
"twitch_authenticator": Provide(get_authenticator),
|
||||
"authenticate_action": Provide(get_authenticate_action),
|
||||
|
|
@ -115,8 +115,9 @@ def create_app():
|
|||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
s = Settings.get()
|
||||
config = uvicorn.Config("main:app", host=s.host, port=s.port, log_level="info")
|
||||
config = uvicorn.Config("main:app", host=s.port, port=s.port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
server.run()
|
||||
|
|
|
|||
|
|
@ -12,14 +12,8 @@ class CreateQuoteAction(BaseModel):
|
|||
create_quote_svc: CreateQuoteSvc
|
||||
is_mod_svc: IsModSvc
|
||||
|
||||
async def run( # noqa: PLR0913
|
||||
self,
|
||||
user: User,
|
||||
channel: str,
|
||||
quote: str,
|
||||
author: str,
|
||||
username: str,
|
||||
is_active: bool = True,
|
||||
async def run(
|
||||
self, user: User, channel: str, quote: str, author: str, username: str
|
||||
) -> Quote | None:
|
||||
if not await self.is_mod_svc.run(user=user, username=username, channel=channel):
|
||||
return None
|
||||
|
|
@ -29,7 +23,6 @@ class CreateQuoteAction(BaseModel):
|
|||
author=author,
|
||||
channel_name=channel,
|
||||
created_at=datetime.datetime.now(datetime.UTC),
|
||||
is_active=is_active,
|
||||
last_updated_at=datetime.datetime.now(datetime.UTC),
|
||||
)
|
||||
return await self.create_quote_svc.run(new_quote)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from tenacity import (
|
|||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
from twitchio import Channel, Message
|
||||
from twitchio import Channel
|
||||
from twitchio.ext import commands, routines
|
||||
|
||||
from huesoporro.actions.quotes.create_quote_action import CreateQuoteAction
|
||||
|
|
@ -151,7 +151,6 @@ class MessageType(StrEnum):
|
|||
YES = "YES"
|
||||
WHAT = "WHAT"
|
||||
LAUGH = "LAUGH"
|
||||
ANO_SUFFIX = "ANO_SUFFIX"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
|
|
@ -169,14 +168,6 @@ class MessageHandler:
|
|||
"keking",
|
||||
"KEKW",
|
||||
"OMEGADANCEBUTFAST",
|
||||
"xdd",
|
||||
"xdding",
|
||||
]
|
||||
self.ano_suffix_reply_patterns = [
|
||||
"me la agarras con la mano. venga, tira",
|
||||
"me la agarras con la mano, espabila",
|
||||
"me la agarras con la mano y te falta calle",
|
||||
"vegetasmile",
|
||||
]
|
||||
self.send = channel_send_func
|
||||
|
||||
|
|
@ -184,22 +175,18 @@ class MessageHandler:
|
|||
"""Determines the type of message based on its content"""
|
||||
if content.startswith("!"):
|
||||
return MessageType.COMMAND
|
||||
if content in ["Yes", "yes"]:
|
||||
if content == "Yes":
|
||||
return MessageType.YES
|
||||
if content.startswith("WHAT"):
|
||||
return MessageType.WHAT
|
||||
if content.endswith("ano") and len(content) > 3: # noqa: PLR2004
|
||||
return MessageType.ANO_SUFFIX
|
||||
if content in self.laugh_patterns:
|
||||
return MessageType.LAUGH
|
||||
return MessageType.OTHER
|
||||
|
||||
def handle_laugh(self) -> str:
|
||||
async def handle_laugh(self) -> str:
|
||||
"""Handles laugh messages"""
|
||||
return random.choice(self.laugh_patterns) # noqa: S311
|
||||
|
||||
def handle_ano_suffix(self) -> str:
|
||||
return random.choice(self.ano_suffix_reply_patterns) # noqa: S311
|
||||
|
||||
|
||||
class SaveMessagesCog(commands.Cog):
|
||||
def __init__(self, bot: Bot):
|
||||
|
|
@ -213,7 +200,6 @@ class SaveMessagesCog(commands.Cog):
|
|||
MessageType.YES: self._create_typed_send("yes"),
|
||||
MessageType.WHAT: self._create_typed_send("what"),
|
||||
MessageType.LAUGH: self._create_typed_send("laugh"),
|
||||
MessageType.ANO_SUFFIX: self._create_typed_send("ano_suffix"),
|
||||
}
|
||||
|
||||
for func in self.send_functions.values():
|
||||
|
|
@ -235,53 +221,19 @@ class SaveMessagesCog(commands.Cog):
|
|||
if hasattr(self, "current_message"):
|
||||
await self.current_message.channel.send(content)
|
||||
|
||||
def is_bot_mention(self, tok: str) -> bool:
|
||||
return tok.lower() == str(self.bot.nick).lower()
|
||||
|
||||
async def _handle_bot_mention(self, message: Message) -> str | None:
|
||||
content = (message.content or "").strip()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
tokens = content.split()
|
||||
contains_mention = any(self.is_bot_mention(t) for t in tokens)
|
||||
if not contains_mention:
|
||||
return None
|
||||
|
||||
# Find the first non-mention token as seed
|
||||
non_mention_tokens = (
|
||||
t.strip(".,!?;:") for t in tokens if not self.is_bot_mention(t)
|
||||
)
|
||||
seed = next((t for t in non_mention_tokens if t), None)
|
||||
|
||||
if not seed:
|
||||
return None
|
||||
|
||||
sentence = await self.generate_svc.run(seed)
|
||||
if not sentence:
|
||||
return None
|
||||
|
||||
await message.channel.send(f"@{message.author.name} {sentence}")
|
||||
|
||||
return sentence
|
||||
|
||||
@commands.Cog.event()
|
||||
async def event_message(self, message):
|
||||
"""Main message event handler"""
|
||||
if not message.author:
|
||||
return
|
||||
|
||||
# Store reference to current message for send functions
|
||||
self.current_message = message
|
||||
|
||||
# Store the message content
|
||||
await self.store_svc.run(message.content)
|
||||
|
||||
# If the message contains a mention to this bot, reply by generating
|
||||
# a sentence from the first word that is not the bot username itself.
|
||||
if await self._handle_bot_mention(message):
|
||||
# If the bot actually replies with something, it should not try to send
|
||||
# any other type of reply
|
||||
return
|
||||
|
||||
# Determine message type and handle accordingly
|
||||
msg_type = self.message_handler.get_message_type(message.content)
|
||||
|
||||
response = None
|
||||
|
|
@ -294,15 +246,12 @@ class SaveMessagesCog(commands.Cog):
|
|||
case MessageType.WHAT:
|
||||
response = "WHAT Ramon"
|
||||
case MessageType.LAUGH:
|
||||
response = self.message_handler.handle_laugh()
|
||||
case MessageType.ANO_SUFFIX:
|
||||
response = (
|
||||
f"@{message.author.name} {self.message_handler.handle_ano_suffix()}"
|
||||
)
|
||||
response = await self.message_handler.handle_laugh()
|
||||
case MessageType.OTHER:
|
||||
return
|
||||
|
||||
if response and msg_type in self.send_functions:
|
||||
# Use the type-specific send function
|
||||
await self.backoff_svc.call_async(self.send_functions[msg_type], response)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ class IRepo(BaseModel, ABC, Generic[T]):
|
|||
pass # pragma: no cover
|
||||
|
||||
@abstractmethod
|
||||
async def list(self, offset: int = 0, limit: int = 10, auto_commit=True) -> list[T]:
|
||||
async def list(
|
||||
self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[T]:
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
|
|
@ -62,9 +64,7 @@ class UserRepo(IRepo[User]):
|
|||
self.get_client(auto_commit=auto_commit) as db,
|
||||
await db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
SELECT * FROM users WHERE id = ?
|
||||
""",
|
||||
(obj_id.hex,),
|
||||
) as cursor,
|
||||
|
|
@ -81,7 +81,8 @@ class UserRepo(IRepo[User]):
|
|||
self.get_client(auto_commit=auto_commit) as db,
|
||||
await db.execute(
|
||||
"""INSERT INTO users (id, username, external_auth, created_at, last_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?) RETURNING *
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
obj.id.hex,
|
||||
|
|
@ -103,12 +104,13 @@ class UserRepo(IRepo[User]):
|
|||
self.get_client(auto_commit=auto_commit) as db,
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET username = ?,
|
||||
external_auth = ?,
|
||||
last_updated_at = ?
|
||||
WHERE id = ? RETURNING *
|
||||
""",
|
||||
UPDATE users
|
||||
SET username = ?,
|
||||
external_auth = ?,
|
||||
last_updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
obj.username,
|
||||
obj.serialize_external_auth(),
|
||||
|
|
@ -124,9 +126,7 @@ class UserRepo(IRepo[User]):
|
|||
async with self.get_client(auto_commit=auto_commit) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
DELETE FROM users WHERE id = ?
|
||||
""",
|
||||
(obj.id.hex,),
|
||||
)
|
||||
|
|
@ -136,10 +136,8 @@ class UserRepo(IRepo[User]):
|
|||
self.get_client(auto_commit=auto_commit) as db,
|
||||
db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
""",
|
||||
SELECT * FROM users WHERE username = ?
|
||||
""",
|
||||
(user,),
|
||||
) as cursor,
|
||||
):
|
||||
|
|
@ -155,7 +153,7 @@ class UserRepo(IRepo[User]):
|
|||
)
|
||||
|
||||
async def list( # type: ignore[empty-body]
|
||||
self, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
self, obj: User, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[User]:
|
||||
pass # pragma: no cover
|
||||
|
||||
|
|
@ -173,18 +171,16 @@ class QuoteRepo(IRepo[Quote]):
|
|||
channel_name=data["channel"],
|
||||
created_at=data["created_at"],
|
||||
last_updated_at=data["last_updated_at"],
|
||||
is_active=data["is_active"],
|
||||
)
|
||||
|
||||
async def create(self, obj: Quote, auto_commit=True) -> Quote:
|
||||
if await self.get_by_quote(obj.quote):
|
||||
raise ValueError(f"Quote {obj.quote} already exists")
|
||||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO quotes (id, quote, author, channel, created_at, is_active, last_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *
|
||||
INSERT INTO quotes (id, quote, author, channel, created_at, last_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
obj.id.hex,
|
||||
|
|
@ -192,7 +188,6 @@ class QuoteRepo(IRepo[Quote]):
|
|||
obj.author,
|
||||
obj.channel_name,
|
||||
obj.created_at,
|
||||
obj.is_active,
|
||||
obj.last_updated_at,
|
||||
),
|
||||
) as cursor,
|
||||
|
|
@ -201,99 +196,28 @@ class QuoteRepo(IRepo[Quote]):
|
|||
return self._deserialize(data)
|
||||
|
||||
async def update(self, obj: Quote, auto_commit=True) -> Quote: # type: ignore[empty-body]
|
||||
if not await self.get_by_id(obj.id):
|
||||
raise ValueError(f"Quote {obj.id} does not exist")
|
||||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE quotes
|
||||
SET quote = ?,
|
||||
author = ?,
|
||||
channel = ?,
|
||||
is_active = ?,
|
||||
last_updated_at = ?
|
||||
WHERE id = ? RETURNING *
|
||||
""",
|
||||
(
|
||||
obj.quote,
|
||||
obj.author,
|
||||
obj.channel_name,
|
||||
obj.is_active,
|
||||
utils.get_utc_now(),
|
||||
obj.id.hex,
|
||||
),
|
||||
) as cursor,
|
||||
):
|
||||
data = await cursor.fetchone()
|
||||
return self._deserialize(data)
|
||||
pass # pragma: no cover
|
||||
|
||||
async def delete(self, obj: Quote, auto_commit=True):
|
||||
async with self.get_client(auto_commit=auto_commit) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE
|
||||
FROM quotes
|
||||
WHERE id = ?
|
||||
""",
|
||||
(obj.id.hex,),
|
||||
)
|
||||
pass # pragma: no cover
|
||||
|
||||
async def get_by_id(self, obj_id: UUID, auto_commit=True) -> Quote | None: # type: ignore[empty-body]
|
||||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM quotes
|
||||
WHERE id = ?
|
||||
""",
|
||||
(obj_id.hex,),
|
||||
) as cursor,
|
||||
):
|
||||
data = await cursor.fetchone()
|
||||
if not data:
|
||||
return None
|
||||
return self._deserialize(data)
|
||||
|
||||
async def get_by_quote(self, quote: str, auto_commit=True) -> Quote | None:
|
||||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM quotes
|
||||
WHERE quote = ?
|
||||
""",
|
||||
(quote,),
|
||||
) as cursor,
|
||||
):
|
||||
data = await cursor.fetchone()
|
||||
if not data:
|
||||
return None
|
||||
return self._deserialize(data)
|
||||
pass # pragma: no cover
|
||||
|
||||
async def list( # type: ignore[empty-body]
|
||||
self, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[Quote]:
|
||||
async with self.get_client() as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM quotes LIMIT ? OFFSET ?", (limit, offset)
|
||||
) as cursor:
|
||||
results = await cursor.fetchall()
|
||||
return [self._deserialize(result) for result in results]
|
||||
self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[T]:
|
||||
pass # pragma: no cover
|
||||
|
||||
async def get_random(self, channel_name: str, auto_commit=True) -> Quote | None:
|
||||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM quotes
|
||||
WHERE channel = ?
|
||||
AND is_active = 1
|
||||
ORDER BY RANDOM() LIMIT 1
|
||||
SELECT * FROM quotes
|
||||
WHERE channel = ?
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 1
|
||||
""",
|
||||
(channel_name,),
|
||||
) as cursor,
|
||||
|
|
@ -323,15 +247,17 @@ class ChatbotRepo(IRepo[Chatbot]):
|
|||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
await db.execute(
|
||||
"""INSERT INTO chatbot (id,
|
||||
user_id,
|
||||
automatic_generation_timer,
|
||||
automatic_quote_timer,
|
||||
mods,
|
||||
created_at,
|
||||
last_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *
|
||||
""",
|
||||
"""INSERT INTO chatbot (
|
||||
id,
|
||||
user_id,
|
||||
automatic_generation_timer,
|
||||
automatic_quote_timer,
|
||||
mods,
|
||||
created_at,
|
||||
last_updated_at
|
||||
) VALUES(?,?,?,?,?,?,?)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
obj.id.hex,
|
||||
obj.user_id.hex,
|
||||
|
|
@ -352,12 +278,13 @@ class ChatbotRepo(IRepo[Chatbot]):
|
|||
async with (
|
||||
self.get_client(auto_commit=auto_commit) as db,
|
||||
await db.execute(
|
||||
"""UPDATE chatbot
|
||||
SET automatic_generation_timer = ?,
|
||||
automatic_quote_timer = ?,
|
||||
mods = ?,
|
||||
last_updated_at = ?
|
||||
WHERE user_id = ? RETURNING *
|
||||
"""UPDATE chatbot SET
|
||||
automatic_generation_timer = ?,
|
||||
automatic_quote_timer = ?,
|
||||
mods = ?,
|
||||
last_updated_at = ?
|
||||
WHERE user_id = ?
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
obj.automatic_generation_timer,
|
||||
|
|
@ -371,22 +298,11 @@ class ChatbotRepo(IRepo[Chatbot]):
|
|||
data = await cursor.fetchone()
|
||||
return self._deserialize(data)
|
||||
|
||||
async def delete(self, obj: Chatbot, auto_commit=True):
|
||||
if not await self.get_by_id(obj.id):
|
||||
raise ValueError(f"Chatbot {obj.id} does not exist")
|
||||
async with self.get_client() as db:
|
||||
await db.execute("DELETE FROM chatbot WHERE id = ?", (obj.id.hex,))
|
||||
async def delete(self, obj: T, auto_commit=True):
|
||||
pass # pragma: no cover
|
||||
|
||||
async def get_by_id(self, obj_id: UUID, auto_commit=True) -> Chatbot | None: # type: ignore[empty-body]
|
||||
async with self.get_client() as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM chatbot WHERE id = ?", (obj_id.hex,)
|
||||
) as cursor:
|
||||
result = await cursor.fetchone()
|
||||
if not result:
|
||||
return None
|
||||
return self._deserialize(result)
|
||||
pass # pragma: no cover
|
||||
|
||||
async def get_by_user_id(self, user_id: UUID) -> Chatbot | None:
|
||||
async with self.get_client() as db:
|
||||
|
|
@ -400,12 +316,6 @@ class ChatbotRepo(IRepo[Chatbot]):
|
|||
return self._deserialize(result)
|
||||
|
||||
async def list( # type: ignore[empty-body]
|
||||
self, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[Chatbot]:
|
||||
async with self.get_client() as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM chatbot LIMIT ? OFFSET ?", (limit, offset)
|
||||
) as cursor:
|
||||
results = await cursor.fetchall()
|
||||
return [self._deserialize(result) for result in results]
|
||||
self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[T]:
|
||||
pass # pragma: no cover
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ class Quote(BaseModel):
|
|||
quote: str
|
||||
author: str
|
||||
channel_name: str
|
||||
is_active: bool = True
|
||||
created_at: datetime.datetime = Field(default_factory=utils.get_utc_now)
|
||||
last_updated_at: datetime.datetime = Field(default_factory=utils.get_utc_now)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
import yt_dlp
|
||||
|
|
@ -9,7 +9,7 @@ from pydantic import BaseModel
|
|||
|
||||
class DownloadClosedCaptionsSvc(BaseModel):
|
||||
@staticmethod
|
||||
def run(youtube_url: str, sub_lang: str = "es") -> Iterator[Path]:
|
||||
def run(youtube_url: str, sub_lang: str = "es") -> Generator[Path, None, None]:
|
||||
"""Download closed captions from a yt video and save it to a temp file
|
||||
|
||||
Args:
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ def db(s, cdb):
|
|||
pass
|
||||
|
||||
async def list( # type: ignore[empty-body]
|
||||
self, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
self, obj: BaseModel, offset: int = 0, limit: int = 10, auto_commit=True
|
||||
) -> list[BaseModel]:
|
||||
pass
|
||||
|
||||
|
|
@ -169,16 +169,6 @@ async def chatbot(chatbot_factory, user):
|
|||
return chatbot_factory.build(user_id=user.id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def five_chatbots(chatbot_factory, user):
|
||||
return [chatbot_factory.build() for _ in range(5)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def persisted_five_chatbots(five_chatbots, chatbot_repo):
|
||||
return [await chatbot_repo.create(chatbot) for chatbot in five_chatbots]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def persisted_chatbot(chatbot_repo, chatbot, persisted_user):
|
||||
return await chatbot_repo.create(chatbot)
|
||||
|
|
@ -214,22 +204,11 @@ async def quote(quote_factory):
|
|||
return quote_factory.build()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def five_quotes(quote_factory):
|
||||
return [quote_factory.build() for _ in range(5)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def persisted_quote(quote_repo, quote):
|
||||
quote.is_active = True
|
||||
return await quote_repo.create(quote)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def persisted_five_quotes(five_quotes, quote_repo):
|
||||
return [await quote_repo.create(quote) for quote in five_quotes]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def create_quote_svc(quote_repo):
|
||||
return CreateQuoteSvc(repo=quote_repo)
|
||||
|
|
|
|||
|
|
@ -234,7 +234,6 @@ async def test_create_quote_action(
|
|||
username=user.username,
|
||||
channel=user.username,
|
||||
quote=quote.quote,
|
||||
is_active=quote.is_active,
|
||||
author=quote.author,
|
||||
)
|
||||
assert new_quote
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from huesoporro.bot import SaveMessagesCog
|
||||
|
||||
|
||||
class DummyDB:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class FakeChannel:
|
||||
def __init__(self):
|
||||
self.sent: list[str] = []
|
||||
|
||||
async def send(self, content: str):
|
||||
self.sent.append(content)
|
||||
|
||||
|
||||
class FakeAuthor:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
def __init__(self, content: str | None, author_name: str = "alice"):
|
||||
self.content = content
|
||||
self.author = FakeAuthor(author_name)
|
||||
self.channel = FakeChannel()
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self, nick: str = "Junie", channel: str = "testchan"):
|
||||
self.nick = nick
|
||||
self.channel = channel
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_markov_and_svcs(monkeypatch):
|
||||
monkeypatch.setattr("huesoporro.bot.MarkovDatabase", DummyDB)
|
||||
|
||||
class _DummyStoreSvc:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
async def run(self, content: str | None):
|
||||
return None
|
||||
|
||||
class _DummyGenSvc:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
async def run(self, seed: str):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("huesoporro.bot.SentenceStorerSvc", _DummyStoreSvc)
|
||||
monkeypatch.setattr("huesoporro.bot.SentenceGeneratorSvc", _DummyGenSvc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cog(monkeypatch) -> SaveMessagesCog:
|
||||
bot = FakeBot()
|
||||
return SaveMessagesCog(bot) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def make_async_fn(result=None, exc: Exception | None = None):
|
||||
async def _fn(*args, **kwargs):
|
||||
if exc:
|
||||
raise exc
|
||||
return result
|
||||
|
||||
return _fn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_bot_mention_returns_none_on_empty_content(cog: SaveMessagesCog):
|
||||
msg = FakeMessage(content=None)
|
||||
res = await cog._handle_bot_mention(msg) # type: ignore[attr-defined]
|
||||
assert res is None
|
||||
assert msg.channel.sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_bot_mention_returns_none_when_no_mention(cog: SaveMessagesCog):
|
||||
msg = FakeMessage(content="hello world")
|
||||
res = await cog._handle_bot_mention(msg) # type: ignore[attr-defined]
|
||||
assert res is None
|
||||
assert msg.channel.sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_bot_mention_returns_none_when_only_mention(
|
||||
cog: SaveMessagesCog, monkeypatch
|
||||
):
|
||||
msg = FakeMessage(content=cog.bot.nick) # type: ignore[attr-defined]
|
||||
res = await cog._handle_bot_mention(msg) # type: ignore[attr-defined]
|
||||
assert res is None
|
||||
assert msg.channel.sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_bot_mention_generates_and_sends_reply(
|
||||
cog: SaveMessagesCog, monkeypatch
|
||||
):
|
||||
msg = FakeMessage(content="juNie hello there")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cog, "generate_svc", types.SimpleNamespace(run=make_async_fn("foo bar"))
|
||||
)
|
||||
|
||||
res = await cog._handle_bot_mention(msg) # type: ignore[attr-defined]
|
||||
|
||||
assert res == "foo bar"
|
||||
assert len(msg.channel.sent) == 1
|
||||
assert msg.channel.sent[0].startswith(f"@{msg.author.name} ")
|
||||
assert msg.channel.sent[0].endswith("foo bar")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_bot_mention_no_send_when_generator_returns_none(
|
||||
cog: SaveMessagesCog, monkeypatch
|
||||
):
|
||||
msg = FakeMessage(content=f"{cog.bot.nick} hello") # type: ignore[attr-defined]
|
||||
|
||||
monkeypatch.setattr(
|
||||
cog, "generate_svc", types.SimpleNamespace(run=make_async_fn(None))
|
||||
)
|
||||
|
||||
res = await cog._handle_bot_mention(msg) # type: ignore[attr-defined]
|
||||
assert res is None
|
||||
assert msg.channel.sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_bot_mention_swallows_exceptions_and_returns_none(
|
||||
cog: SaveMessagesCog, monkeypatch
|
||||
):
|
||||
msg = FakeMessage(content=f"{cog.bot.nick} hello") # type: ignore[attr-defined]
|
||||
|
||||
monkeypatch.setattr(
|
||||
cog,
|
||||
"generate_svc",
|
||||
types.SimpleNamespace(run=make_async_fn(exc=RuntimeError("boom"))),
|
||||
)
|
||||
|
||||
res = await cog._handle_bot_mention(msg) # type: ignore[attr-defined]
|
||||
assert res is None
|
||||
assert msg.channel.sent == []
|
||||
|
|
@ -88,79 +88,8 @@ async def test_update_chatbot_raises_value_error_on_non_existing_chatbot(
|
|||
await chatbot_repo.update(chatbot)
|
||||
|
||||
|
||||
async def test_delete_chatbot_raises_value_error_on_non_existing_chatbot(
|
||||
chatbot_repo, chatbot
|
||||
):
|
||||
with pytest.raises(ValueError, match=f"Chatbot {chatbot.id} does not exist"):
|
||||
await chatbot_repo.delete(chatbot)
|
||||
|
||||
|
||||
async def test_delete_chatbot(chatbot_repo, persisted_chatbot):
|
||||
assert await chatbot_repo.delete(persisted_chatbot) is None
|
||||
assert await chatbot_repo.get_by_id(persisted_chatbot.id) is None
|
||||
|
||||
|
||||
async def test_get_by_id(chatbot_repo, persisted_chatbot):
|
||||
chatbot = await chatbot_repo.get_by_id(persisted_chatbot.id)
|
||||
assert chatbot == persisted_chatbot
|
||||
|
||||
|
||||
async def test_list(chatbot_repo, persisted_five_chatbots):
|
||||
chatbots = await chatbot_repo.list()
|
||||
assert len(chatbots) == 5 # noqa: PLR2004
|
||||
|
||||
|
||||
async def test_list_offset_limit(chatbot_repo, persisted_five_chatbots):
|
||||
chatbots = await chatbot_repo.list(offset=1, limit=2)
|
||||
assert len(chatbots) == 2 # noqa: PLR2004
|
||||
|
||||
|
||||
async def test_get_random_quote(quote_repo: QuoteRepo, persisted_quote):
|
||||
quote = await quote_repo.get_random(persisted_quote.channel_name)
|
||||
assert quote
|
||||
assert quote.author == persisted_quote.author
|
||||
assert quote.channel_name == persisted_quote.channel_name
|
||||
|
||||
|
||||
async def test_create_quote_raises_value_error_for_existing_quote(
|
||||
quote_repo: QuoteRepo, persisted_quote
|
||||
):
|
||||
with pytest.raises(
|
||||
ValueError, match=f"Quote {persisted_quote.quote} already exists"
|
||||
):
|
||||
await quote_repo.create(persisted_quote)
|
||||
|
||||
|
||||
async def test_create_quote(quote_repo: QuoteRepo, quote_factory):
|
||||
quote = quote_factory.build()
|
||||
created_quote = await quote_repo.create(quote)
|
||||
assert created_quote == quote
|
||||
|
||||
|
||||
async def test_update_quote_raises_value_error_on_non_existing_quote(
|
||||
quote_repo: QuoteRepo, quote
|
||||
):
|
||||
with pytest.raises(ValueError, match=f"Quote {quote.id} does not exist"):
|
||||
await quote_repo.update(quote)
|
||||
|
||||
|
||||
async def test_update_quote(quote_repo: QuoteRepo, persisted_quote):
|
||||
persisted_quote.quote = "new quote"
|
||||
updated_quote = await quote_repo.update(persisted_quote)
|
||||
persisted_quote.last_updated_at = updated_quote.last_updated_at
|
||||
assert updated_quote == persisted_quote
|
||||
|
||||
|
||||
async def test_delete_quote(quote_repo: QuoteRepo, persisted_quote):
|
||||
assert await quote_repo.delete(persisted_quote) is None
|
||||
assert await quote_repo.get_by_id(persisted_quote.id) is None
|
||||
|
||||
|
||||
async def test_list_quotes(quote_repo, persisted_five_quotes):
|
||||
quotes = await quote_repo.list()
|
||||
assert len(quotes) == 5 # noqa: PLR2004
|
||||
|
||||
|
||||
async def test_list_quotes_offset_limit(quote_repo, persisted_five_quotes):
|
||||
quotes = await quote_repo.list(offset=1, limit=2)
|
||||
assert len(quotes) == 2 # noqa: PLR2004
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue