diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 3d86958..90667b7 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -50,4 +50,4 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with pytest run: | - pytest + pytest tests diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..ba98e5f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,110 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migration + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migration/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migration/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql://postgres:123456@localhost/finances + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migration/README b/migration/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/migration/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/migration/env.py b/migration/env.py new file mode 100644 index 0000000..2fbb470 --- /dev/null +++ b/migration/env.py @@ -0,0 +1,77 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from src.database import DatabaseFactory + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +database_factory = DatabaseFactory() +target_metadata = database_factory.mapper_registry.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migration/script.py.mako b/migration/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/migration/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migration/versions/86f9d6304908_initial.py b/migration/versions/86f9d6304908_initial.py new file mode 100644 index 0000000..24e65e7 --- /dev/null +++ b/migration/versions/86f9d6304908_initial.py @@ -0,0 +1,77 @@ +"""initial + +Revision ID: 86f9d6304908 +Revises: +Create Date: 2023-04-19 13:59:59.584998 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "86f9d6304908" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("hashed_password", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_table( + "banks_info", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("bank_name", sa.String(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "operations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("amount", sa.Integer(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("time", sa.Integer(), nullable=False), + sa.Column("mcc", sa.Integer(), nullable=True), + sa.Column("source_type", sa.String(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "banks_info_properties", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("value", sa.String(), nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("manager_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["manager_id"], + ["banks_info.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("banks_info_properties") + op.drop_table("operations") + op.drop_table("banks_info") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index a26ea1b..11cc078 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/app/domain/statistic.py b/src/app/domain/statistic.py index 9612808..7123bcb 100644 --- a/src/app/domain/statistic.py +++ b/src/app/domain/statistic.py @@ -4,5 +4,5 @@ @dataclass class Statistic: costs_sum: int - most_popular_category: int categories_costs: dict[int, int] + costs_num_by_days: dict[str, int] diff --git a/src/app/repositories/bank_api/bank_info.py b/src/app/repositories/bank_api/bank_info.py index c385bc4..330f307 100644 --- a/src/app/repositories/bank_api/bank_info.py +++ b/src/app/repositories/bank_api/bank_info.py @@ -50,7 +50,7 @@ async def set_update_time_to_managers(self, ids: list[int]): await self.session.execute( update(BankInfoProperty) .filter( - BankInfoProperty.manager.in_(ids), + BankInfoProperty.manager_id.in_(ids), BankInfoProperty.name == "updated_time", ) .values(updated_time=datetime.now().timestamp()) diff --git a/src/app/repositories/operations.py b/src/app/repositories/operations.py index d4f0d31..0a768be 100644 --- a/src/app/repositories/operations.py +++ b/src/app/repositories/operations.py @@ -1,5 +1,3 @@ -from datetime import datetime, timedelta - from sqlalchemy import select from src.app.domain.operations import Operation @@ -12,17 +10,8 @@ async def get(self, field, value) -> Operation: return await self._get(Operation, field, value) async def get_all_by_user( - self, - user_id, - from_time: int = None, - to_time: int = None, + self, user_id, from_time: int, to_time: int ) -> list[Operation]: - from_time = ( - from_time - if from_time - else int((datetime.now() - timedelta(days=1)).timestamp()) - ) - to_time = to_time if to_time else int(datetime.now().timestamp()) return list( await self.session.scalars( select(Operation) diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index 012ca4c..8eb1d7e 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -52,6 +52,8 @@ async def get_bank_managers_by_user( async def update_banks_costs( uow: AbstractUnitOfWork, managers: list[ABankManagerRepository] ): + if len(managers) == 0: + return async with uow: costs = [] updated_managers = [] diff --git a/src/app/services/operations.py b/src/app/services/operations.py index 030bbf7..2375a0b 100644 --- a/src/app/services/operations.py +++ b/src/app/services/operations.py @@ -16,21 +16,23 @@ async def create_operation( :return: Operation якщо user_id існує в базі. None, якщо ні """ async with uow: - operation = Operation( - **schema.dict(), time=int(datetime.now().timestamp()), user_id=user_id - ) + schema.time = schema.time if schema.time else int(datetime.now().timestamp()) + operation = Operation(**schema.dict(), user_id=user_id) await uow.operations.add(operation) await uow.commit() return operation -async def get_operations(uow: AbstractUnitOfWork, user_id: int) -> list[Operation]: +async def get_operations( + uow: AbstractUnitOfWork, user_id: int, from_time=None, to_time=None +) -> list[Operation]: """ - Повертає всі операції які містять переданий user_id в полі user_id + Повертнення всих операцій, які містять переданий user_id в полі user_id :param uow: Unit of Work :param user_id: ID користувача, операції якого потрібно отримати + :param from_time: початковий момент часу + :param to_time: кінцевий момент часу :return: список об'єктів моделі Opetation. Якщо операцій немає, то пустий список """ async with uow: - operations = await uow.operations.get_all_by_user(user_id) - return list(operations) + return await uow.operations.get_all_by_user(user_id, from_time, to_time) diff --git a/src/app/services/statistic.py b/src/app/services/statistic.py index 8d11150..8ee5bb5 100644 --- a/src/app/services/statistic.py +++ b/src/app/services/statistic.py @@ -1,3 +1,6 @@ +from collections import Counter +from datetime import datetime + from src.app.domain.operations import Operation from src.app.domain.statistic import Statistic @@ -10,8 +13,8 @@ def get_statistic(operations: list[Operation]) -> Statistic: """ statistic = Statistic( costs_sum=get_costs_sum(operations), - most_popular_category=get_most_popular_category(operations), categories_costs=get_categories_costs(operations), + costs_num_by_days=get_costs_num_by_days(operations), ) return statistic @@ -21,11 +24,6 @@ def get_costs_sum(operations: list[Operation]) -> int: return sum(operation.amount for operation in operations) -def get_most_popular_category(operations: list[Operation]) -> int: - """Категорія з найбільшою витратою""" - return max(operations, key=lambda operation: operation.amount).mcc - - def get_categories_costs(operations: list[Operation]) -> dict[int, int]: """Словник у форматі {<категорія>: <сума витрат по категорії>}""" operations_mcc = [operation.mcc for operation in operations] @@ -35,3 +33,12 @@ def get_categories_costs(operations: list[Operation]) -> dict[int, int]: ) for mcc in operations_mcc } + + +def get_costs_num_by_days(operations: list[Operation]) -> dict[str, int]: + costs_num_by_days = Counter() + for operation in operations: + costs_num_by_days[ + datetime.fromtimestamp(operation.time).strftime("%Y-%m-%d") + ] += 1 + return dict(costs_num_by_days) diff --git a/src/database.py b/src/database.py index b83c5db..7ab566b 100644 --- a/src/database.py +++ b/src/database.py @@ -10,7 +10,7 @@ from src.app.adapters.orm import create_tables, start_mappers -def _get_url(test: bool = False) -> str: +def _get_url(test: bool = False, async_: bool = True) -> str: """ :param test: Якщо True, то підставляє назву тестової бази в URL :return: URL для підлключення до БД @@ -21,7 +21,11 @@ def _get_url(test: bool = False) -> str: os.getenv("DB_PASSWORD"), os.getenv("DB_HOST"), ) - return f"postgresql+asyncpg://{db_user}:{db_password}@{db_host}/{db_name}" + return ( + f"postgresql+asyncpg://{db_user}:{db_password}@{db_host}/{db_name}" + if async_ + else f"postgresql+psycopg2://{db_user}:{db_password}@{db_host}/{db_name}" + ) class Database: diff --git a/src/main.py b/src/main.py index efdcb3b..edad578 100644 --- a/src/main.py +++ b/src/main.py @@ -4,12 +4,14 @@ from fastapi import FastAPI from src.database import DatabaseFactory, bind_database_to_app +from src.middlewares import set_cors_middleware def bootstrap_fastapi_app(db_factory=DatabaseFactory(), test=False) -> FastAPI: """Налаштування FastApi для початку роботи, включаючи базу""" fastapi_app = FastAPI() include_routers(fastapi_app) + set_cors_middleware(fastapi_app) database = db_factory.get_database(test=test) bind_database_to_app(fastapi_app, database) # Прив'язка залежності, яка віддає сесію бази diff --git a/src/middlewares.py b/src/middlewares.py new file mode 100644 index 0000000..f199915 --- /dev/null +++ b/src/middlewares.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + + +def set_cors_middleware(app: FastAPI): + origins = ["*"] + + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/src/routers/operations.py b/src/routers/operations.py index cbe91e4..f482d64 100644 --- a/src/routers/operations.py +++ b/src/routers/operations.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + from fastapi import APIRouter, Depends from src.app.domain.users import User @@ -34,6 +36,8 @@ async def create_operation_view( async def read_operations_view( current_user: User = Depends(get_current_user_depend), uow: AbstractUnitOfWork = Depends(get_uow), + from_time: int | None = None, + to_time: int | None = None, ): """ Повертає список операцій поточного користувача. @@ -41,6 +45,12 @@ async def read_operations_view( :param uow: Unit of Work :param current_user: Користувач, який розшифровується з токену у заголовку Authorization + :param from_time: з якого часу в unix форматі + :param to_time: по який час в unix форматі :return: Operation list """ - return await get_operations(uow, current_user.id) + from_time = from_time if from_time else int(datetime.today().timestamp()) + to_time = ( + to_time if to_time else int((datetime.today() + timedelta(days=1)).timestamp()) + ) + return await get_operations(uow, current_user.id, from_time, to_time) diff --git a/src/routers/statistic.py b/src/routers/statistic.py index facc14d..340a0ec 100644 --- a/src/routers/statistic.py +++ b/src/routers/statistic.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + from fastapi import APIRouter, Depends from src.app.domain.users import User @@ -14,6 +16,10 @@ async def get_statistic_view( current_user: User = Depends(get_current_user_depend), uow: AbstractUnitOfWork = Depends(get_uow), + from_time: int = None, + to_time: int = None, ): - operations = await get_operations(uow, current_user.id) + from_time = from_time if from_time else datetime.today().timestamp() + to_time = to_time if to_time else (datetime.today() + timedelta(days=1)).timestamp() + operations = await get_operations(uow, current_user.id, from_time, to_time) return get_statistic(operations) diff --git a/src/schemas/operations.py b/src/schemas/operations.py index c77e459..2dcafca 100644 --- a/src/schemas/operations.py +++ b/src/schemas/operations.py @@ -11,13 +11,13 @@ class OperationCreateSchema(BaseModel): description: str | None mcc: int source_type: str + time: int | None class OperationSchema(OperationCreateSchema): """Схема операції, яка виокристовується під час завантаження даних з БД""" id: int - time: int class Config: orm_mode = True diff --git a/src/schemas/statistic.py b/src/schemas/statistic.py index fbe1fa5..ef52349 100644 --- a/src/schemas/statistic.py +++ b/src/schemas/statistic.py @@ -5,5 +5,5 @@ class StatisticSchema(BaseModel): """Схема операції. Модель: src.app.domain.unit.Statistic""" costs_sum: int - most_popular_category: int categories_costs: dict[int, int] + costs_num_by_days: dict[str, int] diff --git a/tests/unit/operations_test.py b/tests/unit/operations_test.py index 0186761..63b6e68 100644 --- a/tests/unit/operations_test.py +++ b/tests/unit/operations_test.py @@ -1,11 +1,11 @@ import random import time -from datetime import datetime +from datetime import datetime, timedelta import pytest from src.app.domain.operations import Operation -from src.app.services.operations import create_operation +from src.app.services.operations import create_operation, get_operations from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.app.services.users import create_user from src.schemas.operations import OperationCreateSchema @@ -65,3 +65,39 @@ async def test_read_operations(database): # noqa: F811; assert isinstance(operations, list) assert isinstance(operations[0], Operation) assert len(operations) == 10 + + +@pytest.mark.asyncio +@precents_evn_variables +async def test_read_operations_in_date_range(database): + async with database.sessionmaker() as session: + uow = SqlAlchemyUnitOfWork(session) + user_schema = UserCreateSchema(email="test", password="test") # nosec B106 + user = await create_user(uow, user_schema) + + todays_operation_schema = OperationCreateSchema( + amount=random.randint(-10000, -10), + description="description", + time=datetime.now().timestamp(), + mcc=random.randint(1000, 9999), + source_type="manual", + ) + await create_operation(uow, user.id, todays_operation_schema) + async with uow: + yesterdays_operation = Operation( + amount=random.randint(-10000, -10), + description="description", + time=int((datetime.now() - timedelta(days=1)).timestamp()), + mcc=random.randint(1000, 9999), + source_type="manual", + user_id=user.id, + ) + await uow.operations.add(yesterdays_operation) + await uow.commit() + + today = datetime.today().timestamp() + tomorrow = (datetime.today() + timedelta(days=1)).timestamp() + operations = await get_operations( + uow, user.id, from_time=int(today), to_time=int(tomorrow) + ) + assert len(operations) == 1 diff --git a/tests/unit/statistic.py b/tests/unit/statistic.py index 20767ed..5977be1 100644 --- a/tests/unit/statistic.py +++ b/tests/unit/statistic.py @@ -1,5 +1,6 @@ -import random -from datetime import datetime +from collections import Counter +from datetime import datetime, timedelta +from random import randint import pytest @@ -12,9 +13,9 @@ async def test_get_statistic(): # TODO: Переписати без рандомних операцій і перевірити результат operations = [ Operation( - amount=random.randint(-10000, -10), + amount=randint(-10000, -10), description="description", - mcc=random.randint(9996, 9999), + mcc=randint(9996, 9999), source_type="manual", time=int(datetime.now().timestamp()), user_id=1, @@ -23,3 +24,46 @@ async def test_get_statistic(): ] get_statistic(operations) assert True + + +@pytest.mark.asyncio +async def test_get_statistic_with_empty_operations(): + operations = [] + statictic = get_statistic(operations) + + assert statictic.costs_sum == 0 + assert statictic.categories_costs == {} + + +@pytest.mark.asyncio +async def test_statistic_by_days(): + """ + Статистика повинна містити в собі поле кількості операцій + на кожен день у наступному вигляді: + + costs_num_by_days: dict[: str, : int] + + В тесті створюються операції, записується їх кількість по дням + а потім йде перевірка на відповідність зі отриманою статистикою + """ + operations_num_by_days = {} + operations = [] + now = datetime.now() + date = datetime(now.year, now.month, now.day) - timedelta(days=6) + for _ in range(5): + date = date + timedelta(days=1) + opertations_num = randint(1, 10) + operations_num_by_days[date.strftime("%Y-%m-%d")] = opertations_num + operations += [ + Operation( + amount=randint(-10000, -10), + description="description", + mcc=randint(9990, 9999), + source_type="manual", + time=int(date.timestamp() + randint(1, 8399)), # random time of day + user_id=1, + ) + for _ in range(opertations_num) + ] + statistic = get_statistic(operations) + assert statistic.costs_num_by_days == operations_num_by_days diff --git a/tests_playwright/__init__.py b/tests_playwright/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests_playwright/test_auth.py b/tests_playwright/test_auth.py new file mode 100644 index 0000000..dc5fdd4 --- /dev/null +++ b/tests_playwright/test_auth.py @@ -0,0 +1,78 @@ +import re +import time + +from playwright.sync_api import Page +from sqlalchemy import create_engine, text + +from src.database import _get_url + + +def delete_user(): + engine = create_engine(_get_url(async_=False)) + with engine.connect() as connection: + user_id = connection.execute( + text("SELECT id FROM public.users WHERE email = 'test';") + ).first() + user_id = user_id[0] if user_id else None + if user_id: + connection.execute( + text(f"DELETE FROM public.operations WHERE user_id = {user_id};") + ) + connection.execute(text("DELETE FROM public.users WHERE email = 'test';")) + connection.commit() + + +def login(page): + page.get_by_role("button", name="Sign In").click() + page.get_by_placeholder("email").fill("test") + page.get_by_placeholder("password").click() + page.get_by_placeholder("password").fill("test") + page.get_by_role("button", name="Login").click() + + +def test_auth(page: Page) -> None: + delete_user() + page.goto("http://127.0.0.1:3000/") + page.get_by_role("button", name="Sign Up").click() + page.get_by_placeholder("email").fill("test") + page.get_by_placeholder("password", exact=True).click() + page.get_by_placeholder("password", exact=True).fill("test") + page.get_by_placeholder("confirm password").click() + page.get_by_placeholder("confirm password").fill("test") + page.get_by_role("button", name="Register").click() + login(page) + assert page.get_by_role("button", name="Додати").is_enabled() + + +def test_add_operation(page: Page) -> None: + page.goto("http://127.0.0.1:3000/") + login(page) + page.get_by_role("button", name="Додати").click() + page.get_by_placeholder("сума").fill("100") + page.get_by_placeholder("опис").fill("test") + page.locator("svg").click() + time.sleep(0.3) + page.get_by_text("Ветеринарні послуги", exact=True).last.click() + page.get_by_role("button", name="Додати").click() + time.sleep(0.3) + assert ( + page.get_by_text( + re.compile("-100 ₴testВетеринарні послугиручний спосіб") + ).count() + > 0 + ) + + +def test_add_old_operation(page: Page) -> None: + page.goto("http://127.0.0.1:3000/") + login(page) + page.get_by_role("button", name="Додати").click() + page.get_by_placeholder("сума").fill("100") + page.get_by_placeholder("опис").fill("test") + page.get_by_placeholder("час").fill("2023-04-25T05:15") + page.locator("svg").click() + time.sleep(0.3) + page.get_by_text("Продукти", exact=True).click() + page.get_by_role("button", name="Додати").click() + page.get_by_placeholder("dashboard-time").fill("2023-04-25") + assert page.get_by_text("-100 ₴testПродуктиручний спосіб05:15").count() == 1