Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions funpaybotengine/client/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
CalcResult,
OfferFields,
Subcategory,
ReviewsBatch,
RunnerResponse,
TransactionFilter,
OrderPreviewsBatch,
Expand All @@ -43,6 +44,7 @@
GetSales,
MuteChat,
CalcChips,
GetReviews,
CheckBanned,
GetChatPage,
GetMainPage,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions funpaybotengine/methods/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions funpaybotengine/methods/get_reviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,25 @@
__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
from funpaybotengine.methods.base import FunPayMethod
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'
Expand All @@ -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
14 changes: 13 additions & 1 deletion funpaybotengine/methods/get_transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand All @@ -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
19 changes: 19 additions & 0 deletions funpaybotengine/types/reviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '',
)
138 changes: 138 additions & 0 deletions tests/pagination_filters_test.py
Original file line number Diff line number Diff line change
@@ -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 = """
<div class="review-container">
<div class="review-item-date">2 месяца назад</div>
<div class="review-item-text">Всё отлично</div>
<div class="review-item-detail">Игра, 100 ₽</div>
<div class="review-item-user"><a href="/users/777/"><img src="/img/a.jpg"/>
<div class="media-user-name">Buyer</div></a></div>
</div>
"""

TRANSACTIONS_HTML = """
<div class="tc-item transaction-status-complete" data-transaction="10">
<span class="tc-date-time">1 января, 12:00</span>
<span class="tc-title">Заказ #ABCDEFGH</span>
<div class="tc-price">+100 ₽</div>
</div>
"""


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}]