diff --git a/funpaybotengine/client/bot.py b/funpaybotengine/client/bot.py index c1cf20c..363d833 100644 --- a/funpaybotengine/client/bot.py +++ b/funpaybotengine/client/bot.py @@ -21,6 +21,7 @@ CalcResult, OfferFields, Subcategory, + ReviewsBatch, RunnerResponse, TransactionFilter, OrderPreviewsBatch, @@ -43,6 +44,7 @@ GetSales, MuteChat, CalcChips, + GetReviews, CheckBanned, GetChatPage, GetMainPage, @@ -730,6 +732,33 @@ async def get_purchases( return (await method.execute(self)).response_obj + async def get_reviews( + self, + user_id: int, + from_review_id: str = '', + filter: str = '', + ) -> ReviewsBatch: + """ + Fetch a batch of reviews left for the given user. + + If ``from_review_id`` is provided, the method retrieves reviews after the + specified review ID, enabling pagination. + + :param user_id: ID of the user whose reviews to fetch. + :param from_review_id: Optional. The review ID to start pagination from. + :param filter: Optional. Rating filter: ``''`` for all reviews, + or ``'1'`` ... ``'5'`` to only include reviews with the given amount of stars. + + :return: A batch of reviews (``ReviewsBatch``). + """ + return ( + await GetReviews( + user_id=user_id, + from_review_id=from_review_id, + filter=filter, + ).execute(self) + ).response_obj + @overload async def get_offer_fields( self, diff --git a/funpaybotengine/methods/__init__.py b/funpaybotengine/methods/__init__.py index 1ebb98c..8ee350d 100644 --- a/funpaybotengine/methods/__init__.py +++ b/funpaybotengine/methods/__init__.py @@ -8,6 +8,7 @@ from .get_sales import GetSales as GetSales from .mute_chat import MuteChat as MuteChat from .calc_chips import CalcChips as CalcChips +from .get_reviews import GetReviews as GetReviews from .check_banned import CheckBanned as CheckBanned from .raise_offers import RaiseOffers as RaiseOffers from .upload_image import UploadImage as UploadImage diff --git a/funpaybotengine/methods/get_reviews.py b/funpaybotengine/methods/get_reviews.py index d1834bf..7cf8278 100644 --- a/funpaybotengine/methods/get_reviews.py +++ b/funpaybotengine/methods/get_reviews.py @@ -4,6 +4,9 @@ __all__ = ['GetReviews'] +from typing import TYPE_CHECKING, Any + +from funpayparsers.types.reviews import ReviewsBatch as ParsedReviewsBatch from funpayparsers.parsers.reviews_parser import ReviewsParser from funpaybotengine.types import ReviewsBatch @@ -11,11 +14,15 @@ from funpaybotengine.client.session.http_methods import HTTPMethod +if TYPE_CHECKING: + from funpaybotengine.client import RawResponse + + class GetReviews(FunPayMethod[ReviewsBatch]): """ - Get a sales list (``https://funpay.com/orders/trade``). + Get a reviews list of a user (``https://funpay.com/users/reviews``). - Returns ``funpaybotengine.types.OrderPreviewsBatch`` obj. + Returns ``funpaybotengine.types.ReviewsBatch`` obj. """ url = 'users/reviews' @@ -27,3 +34,13 @@ class GetReviews(FunPayMethod[ReviewsBatch]): user_id: int from_review_id: str = '' filter: str = '' + + async def parse_result(self, response: RawResponse[Any]) -> ParsedReviewsBatch: + result: ParsedReviewsBatch = await super().parse_result(response) + + # FunPay omits the hidden ``user_id`` / ``filter`` inputs in a part of the + # ``users/reviews`` responses, so the parsed values are unreliable. The requested + # ones are known here and are what the next batch has to be asked with. + result.user_id = self.user_id + result.filter = self.filter + return result diff --git a/funpaybotengine/methods/get_transactions.py b/funpaybotengine/methods/get_transactions.py index 49d0e39..dc08f94 100644 --- a/funpaybotengine/methods/get_transactions.py +++ b/funpaybotengine/methods/get_transactions.py @@ -6,6 +6,9 @@ from typing import TYPE_CHECKING, Any from funpayparsers.parsers import TransactionPreviewsParser +from funpayparsers.types.finances import ( + TransactionPreviewsBatch as ParsedTransactionPreviewsBatch, +) from funpaybotengine.types import TransactionPreviewsBatch from funpaybotengine.types.enums import TransactionFilter @@ -14,7 +17,7 @@ if TYPE_CHECKING: - from funpaybotengine.client import Bot + from funpaybotengine.client import Bot, RawResponse def make_data(m: GetTransactions, bot: Bot) -> dict[str, Any]: @@ -40,3 +43,12 @@ class GetTransactions(FunPayMethod[TransactionPreviewsBatch]): filter: TransactionFilter = TransactionFilter.ALL from_transaction_id: int = 0 + + async def parse_result(self, response: RawResponse[Any]) -> ParsedTransactionPreviewsBatch: + result: ParsedTransactionPreviewsBatch = await super().parse_result(response) + + # FunPay omits the hidden ``filter`` input in a part of the ``users/transactions`` + # responses, so the parsed value is unreliable. The requested filter is known here + # and is what the next batch has to be asked with. + result.filter = self.filter.value + return result diff --git a/funpaybotengine/types/reviews.py b/funpaybotengine/types/reviews.py index e8f4d9e..19768ad 100644 --- a/funpaybotengine/types/reviews.py +++ b/funpaybotengine/types/reviews.py @@ -126,3 +126,22 @@ class ReviewsBatch(FunPayObject, BaseModel): If present, this value should be included in the next request to fetch the following batch of reviews. If ``None``, there are no more reviews to load. """ + + async def next_batch(self) -> ReviewsBatch: + """ + Fetch the next batch of reviews, keeping the filter of the current one. + + :raises ValueError: if this is the last batch, or if the reviewed user is unknown + (a batch taken from an order page carries no ``user_id`` to paginate with). + """ + if not self.next_review_id: + raise ValueError('Last batch.') + + if self.user_id is None: + raise ValueError('Unknown user id.') + + return await self.get_bound_bot().get_reviews( + user_id=self.user_id, + from_review_id=self.next_review_id, + filter=self.filter or '', + ) diff --git a/tests/pagination_filters_test.py b/tests/pagination_filters_test.py new file mode 100644 index 0000000..1d2de3a --- /dev/null +++ b/tests/pagination_filters_test.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import asyncio +from typing import Any, cast +from http import HTTPStatus + +import pytest +from funpaybotengine.types.enums import TransactionFilter +from funpaybotengine.types.reviews import ReviewsBatch +from funpaybotengine.types.finances import TransactionPreviewsBatch +from funpaybotengine.client.session.base import RawResponse +from funpaybotengine.methods.get_reviews import GetReviews +from funpaybotengine.methods.get_transactions import GetTransactions + + +# Both ``users/reviews`` and ``users/transactions`` may answer without the hidden +# ``filter`` / ``user_id`` inputs, in which case the parser has nothing to read them from. +REVIEWS_HTML = """ +
+
2 месяца назад
+
Всё отлично
+
Игра, 100 ₽
+
+
Buyer
+
+""" + +TRANSACTIONS_HTML = """ +
+ 1 января, 12:00 + Заказ #ABCDEFGH +
+100 ₽
+
+""" + + +def raw_response(method: Any, html: str) -> RawResponse[Any]: + return RawResponse( + url='https://funpay.com/', + status_code=HTTPStatus.OK, + raw_response=html, + headers={}, + cookies={}, + method_obj=method, + context={}, + # Neither ``parse_result`` nor the parsers touch the bot. + executed_as=cast(Any, None), + ) + + +def parse(method: Any, html: str) -> Any: + return asyncio.run(method.parse_result(raw_response(method, html))) + + +def test_reviews_response_carries_no_filter_and_user_id() -> None: + # Guards the premise of the fix: without the hidden inputs there is nothing to parse. + from funpayparsers.parsers import ReviewsParser + + parsed = ReviewsParser(REVIEWS_HTML).parse() + + assert parsed.user_id is None + assert parsed.filter is None + + +def test_get_reviews_stamps_requested_filter_and_user_id() -> None: + parsed = parse(GetReviews(user_id=1234, filter='5'), REVIEWS_HTML) + + assert parsed.user_id == 1234 + assert parsed.filter == '5' + + +def test_get_transactions_stamps_requested_filter() -> None: + parsed = parse(GetTransactions(filter=TransactionFilter.WITHDRAW), TRANSACTIONS_HTML) + + assert parsed.filter == TransactionFilter.WITHDRAW.value + + +def build_reviews_batch(**kwargs: Any) -> ReviewsBatch: + return ReviewsBatch.model_validate( + { + 'reviews': (), + 'user_id': None, + 'filter': None, + 'next_review_id': None, + **kwargs, + }, + ) + + +def test_reviews_next_batch_rejects_last_batch() -> None: + batch = build_reviews_batch(user_id=1234, filter='5') + + with pytest.raises(ValueError, match='Last batch.'): + asyncio.run(batch.next_batch()) + + +def test_reviews_next_batch_rejects_unknown_user() -> None: + batch = build_reviews_batch(next_review_id='42') + + with pytest.raises(ValueError, match='Unknown user id.'): + asyncio.run(batch.next_batch()) + + +def test_reviews_next_batch_passes_cursor_and_filter() -> None: + batch = build_reviews_batch(user_id=1234, filter='5', next_review_id='42') + calls: list[dict[str, Any]] = [] + + class BotStub: + async def get_reviews(self, **kwargs: Any) -> ReviewsBatch: + calls.append(kwargs) + return batch + + batch.bind_to(cast(Any, BotStub())) + asyncio.run(batch.next_batch()) + + assert calls == [{'user_id': 1234, 'from_review_id': '42', 'filter': '5'}] + + +def test_transactions_next_batch_keeps_filter() -> None: + batch = TransactionPreviewsBatch.model_validate( + { + 'transactions': (), + 'user_id': None, + 'filter': TransactionFilter.WITHDRAW.value, + 'next_transaction_id': 42, + }, + ) + calls: list[dict[str, Any]] = [] + + class BotStub: + async def get_transactions(self, **kwargs: Any) -> TransactionPreviewsBatch: + calls.append(kwargs) + return batch + + batch.bind_to(cast(Any, BotStub())) + asyncio.run(batch.next_batch()) + + assert calls == [{'from_transaction_id': 42, 'filter': TransactionFilter.WITHDRAW}]