Skip to content

Commit 5b95dd9

Browse files
Integration test edge cases (#70)
This PR adds edge cases to the integration tests, as well as validation rules for the price and quantity to be within given bounds.
2 parents 33d1a8a + 5a2b70d commit 5b95dd9

File tree

3 files changed

+194
-4
lines changed

3 files changed

+194
-4
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
## New Features
1212

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
* Extra validation check to ensure the quantity is strictly positive.
14+
* Extra validation check to ensure the quantity and price are within the allowed bounds.
15+
* Add more edge cases to the integration tests
1416

1517
## Bug Fixes
1618

integration_tests/test_api.py

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
GRIDPOOL_ID = int(GRIDPOOL_ID)
3535
SERVER_URL = "grpc://electricity-trading-testing.api.frequenz.com:443?ssl=true"
3636

37+
MIN_QUANTITY_MW = Decimal("0.1")
38+
MIN_PRICE = Decimal(-9999.0)
39+
MAX_PRICE = Decimal(9999.0)
3740

3841
@pytest.fixture
3942
async def set_up() -> dict[str, Any]:
@@ -57,6 +60,7 @@ async def set_up() -> dict[str, Any]:
5760
price = Price(amount=Decimal("56"), currency=Currency.EUR)
5861
quantity = Power(mw=Decimal("0.1"))
5962
order_type = OrderType.LIMIT
63+
valid_until = None
6064

6165
return {
6266
"client": client,
@@ -65,26 +69,36 @@ async def set_up() -> dict[str, Any]:
6569
"price": price,
6670
"quantity": quantity,
6771
"order_type": order_type,
72+
"valid_until": valid_until,
6873
}
6974

7075

7176
async def create_test_order(
7277
set_up: dict[str, Any],
7378
side: MarketSide = MarketSide.BUY,
7479
price: Price | None = None,
80+
quantity: Power | None = None,
7581
delivery_period: DeliveryPeriod | None = None,
82+
delivery_area: DeliveryArea | None = None,
83+
order_type: OrderType | None = None,
84+
valid_until: datetime | None = None,
7685
) -> OrderDetail:
7786
"""Create a test order with customizable parameters."""
7887
order_price = price or set_up["price"]
88+
order_quantity = quantity or set_up["quantity"]
7989
order_delivery_period = delivery_period or set_up["delivery_period"]
90+
order_delivery_area = delivery_area or set_up["delivery_area"]
91+
order_type = order_type or set_up["order_type"]
92+
order_valid_until = valid_until or set_up["valid_until"]
8093
order = await set_up["client"].create_gridpool_order(
8194
gridpool_id=GRIDPOOL_ID,
82-
delivery_area=set_up["delivery_area"],
95+
delivery_area=order_delivery_area,
8396
delivery_period=order_delivery_period,
84-
order_type=set_up["order_type"],
97+
order_type=order_type,
8598
side=side,
8699
price=order_price,
87-
quantity=set_up["quantity"],
100+
quantity=order_quantity,
101+
valid_until=order_valid_until,
88102
tag="api-integration-test",
89103
)
90104
return order # type: ignore
@@ -192,6 +206,17 @@ async def test_create_order_invalid_delivery_start_15_minutes_ago(
192206
with pytest.raises(ValueError, match="delivery_period must be in the future"):
193207
await create_test_order(set_up, delivery_period=delivery_period)
194208

209+
@pytest.mark.asyncio
210+
async def test_create_order_invalid_valid_until_one_hour_ago(
211+
set_up: dict[str, Any]
212+
) -> None:
213+
"""Test creating an order with a passed valid until (one hour ago)."""
214+
valid_until = (datetime.now(timezone.utc) - timedelta(hours=1)).replace(
215+
minute=0, second=0, microsecond=0
216+
)
217+
with pytest.raises(ValueError, match="valid_until must be in the future"):
218+
await create_test_order(set_up, valid_until=valid_until)
219+
195220

196221
@pytest.mark.asyncio
197222
async def test_list_gridpool_orders(set_up: dict[str, Any]) -> None:
@@ -376,6 +401,159 @@ async def test_stream_gridpool_trades(set_up: dict[str, Any]) -> None:
376401
except asyncio.TimeoutError:
377402
pytest.fail("Streaming timed out, no trade received in 15 seconds")
378403

404+
@pytest.mark.asyncio
405+
async def test_create_order_zero_quantity(set_up: dict[str, Any]) -> None:
406+
"""Test creating an order with zero quantity."""
407+
zero_quantity = Power(mw=Decimal("0"))
408+
with pytest.raises(ValueError, match="Quantity must be strictly positive"):
409+
await create_test_order(set_up, quantity=zero_quantity)
410+
411+
412+
@pytest.mark.asyncio
413+
async def test_create_order_negative_quantity(set_up: dict[str, Any]) -> None:
414+
"""Test creating an order with a negative quantity."""
415+
negative_quantity = Power(mw=Decimal("-0.1"))
416+
with pytest.raises(ValueError, match="Quantity must be strictly positive"):
417+
await create_test_order(set_up, quantity=negative_quantity)
418+
419+
420+
@pytest.mark.asyncio
421+
async def test_create_order_maximum_price_precision_exceeded(set_up: dict[str, Any]) -> None:
422+
"""Test creating an order with excessive decimal precision in price."""
423+
excessive_precision_price = Price(amount=Decimal("56.123"), currency=Currency.EUR)
424+
with pytest.raises(ValueError, match="cannot have more than 2 decimal places"):
425+
await create_test_order(set_up, price=excessive_precision_price)
426+
427+
@pytest.mark.asyncio
428+
async def test_create_order_maximum_quantity_precision_exceeded(set_up: dict[str, Any]) -> None:
429+
"""Test creating an order with excessive decimal precision in quantity."""
430+
excessive_precision_quantity = Power(mw=Decimal("0.5001"))
431+
with pytest.raises(ValueError, match="The quantity cannot have more than 1 decimal."):
432+
await create_test_order(set_up, quantity=excessive_precision_quantity)
433+
434+
@pytest.mark.asyncio
435+
async def test_cancel_non_existent_order(set_up: dict[str, Any]) -> None:
436+
"""Test canceling a non-existent order and expecting an error."""
437+
non_existent_order_id = 999999
438+
with pytest.raises(grpc.aio.AioRpcError) as excinfo:
439+
await set_up["client"].cancel_gridpool_order(GRIDPOOL_ID, non_existent_order_id)
440+
assert excinfo.value.code() == grpc.StatusCode.UNAVAILABLE, "Cancelling non-existent order should return an error"
441+
442+
443+
@pytest.mark.asyncio
444+
async def test_cancel_already_cancelled_order(set_up: dict[str, Any]) -> None:
445+
"""Test cancelling an order twice to ensure idempotent behavior."""
446+
order = await create_test_order(set_up)
447+
await set_up["client"].cancel_gridpool_order(GRIDPOOL_ID, order.order_id)
448+
with pytest.raises(grpc.aio.AioRpcError) as excinfo:
449+
cancelled_order = await set_up["client"].cancel_gridpool_order(GRIDPOOL_ID, order.order_id)
450+
assert excinfo.value.code() == grpc.StatusCode.INVALID_ARGUMENT, "Order is already cancelled"
451+
452+
453+
@pytest.mark.asyncio
454+
async def test_create_order_with_invalid_delivery_area(set_up: dict[str, Any]) -> None:
455+
"""Test creating an order with an invalid delivery area code."""
456+
invalid_delivery_area = DeliveryArea(code="INVALID_CODE", code_type=EnergyMarketCodeType.EUROPE_EIC)
457+
with pytest.raises(grpc.aio.AioRpcError) as excinfo:
458+
await set_up["client"].create_gridpool_order(
459+
gridpool_id=GRIDPOOL_ID,
460+
delivery_area=invalid_delivery_area,
461+
delivery_period=set_up["delivery_period"],
462+
order_type=set_up["order_type"],
463+
side=MarketSide.BUY,
464+
price=set_up["price"],
465+
quantity=set_up["quantity"],
466+
tag="invalid-delivery-area",
467+
)
468+
assert excinfo.value.code() == grpc.StatusCode.UNAVAILABLE, "Delivery area not found"
469+
470+
471+
@pytest.mark.asyncio
472+
async def test_create_order_below_minimum_quantity(set_up: dict[str, Any]) -> None:
473+
"""Test creating an order with a quantity below the minimum allowed."""
474+
below_min_quantity = Power(mw=MIN_QUANTITY_MW - Decimal("0.01"))
475+
with pytest.raises(ValueError, match=f"Quantity must be at least {MIN_QUANTITY_MW} MW."):
476+
await create_test_order(set_up, quantity=below_min_quantity)
477+
478+
479+
@pytest.mark.asyncio
480+
async def test_create_order_above_maximum_price(set_up: dict[str, Any]) -> None:
481+
"""Test creating an order with a price above the maximum allowed."""
482+
above_max_price = Price(amount=MAX_PRICE + Decimal("0.01"), currency=Currency.EUR)
483+
with pytest.raises(ValueError, match=f"Price must be between {MIN_PRICE} and {MAX_PRICE}."):
484+
await create_test_order(set_up, price=above_max_price)
485+
486+
487+
@pytest.mark.asyncio
488+
async def test_create_order_at_maximum_price(set_up: dict[str, Any]) -> None:
489+
"""Test creating an order with the exact maximum allowed price."""
490+
max_price = Price(amount=MAX_PRICE, currency=Currency.EUR)
491+
order = await create_test_order(set_up, price=max_price)
492+
assert order.order.price.amount == max_price.amount, "Order with maximum price was not created correctly"
493+
494+
@pytest.mark.asyncio
495+
async def test_create_order_at_minimum_quantity_and_price(set_up: dict[str, Any]) -> None:
496+
"""Test creating an order with the exact minimum allowed quantity and price."""
497+
min_quantity = Power(mw=MIN_QUANTITY_MW)
498+
min_price = Price(amount=MIN_PRICE, currency=Currency.EUR)
499+
order = await create_test_order(set_up, quantity=min_quantity, price=min_price)
500+
assert order.order.quantity.mw == min_quantity.mw, \
501+
"Order with minimum quantity was not created correctly"
502+
assert order.order.price.amount == min_price.amount, \
503+
"Order with minimum price was not created correctly"
504+
505+
506+
@pytest.mark.asyncio
507+
async def test_update_order_to_invalid_price(set_up: dict[str, Any]) -> None:
508+
"""Test updating an order to have a price outside the valid range."""
509+
order = await create_test_order(set_up)
510+
invalid_price = Price(amount=MAX_PRICE + Decimal("0.01"), currency=Currency.EUR)
511+
with pytest.raises(ValueError, match=f"Price must be between {MIN_PRICE} and {MAX_PRICE}."):
512+
await set_up["client"].update_gridpool_order(
513+
gridpool_id=GRIDPOOL_ID, order_id=order.order_id, price=invalid_price
514+
)
515+
516+
517+
518+
519+
@pytest.mark.asyncio
520+
async def test_concurrent_cancel_and_update_order(set_up: dict[str, Any]) -> None:
521+
"""Test concurrent cancellation and update of the same order."""
522+
order = await create_test_order(set_up)
523+
new_price = Price(amount=Decimal("50"), currency=Currency.EUR)
524+
525+
cancelled_order = await set_up["client"].cancel_gridpool_order(GRIDPOOL_ID, order.order_id)
526+
527+
with pytest.raises(grpc.aio.AioRpcError) as excinfo:
528+
await set_up["client"].update_gridpool_order(
529+
gridpool_id=GRIDPOOL_ID, order_id=order.order_id, price=new_price
530+
)
531+
assert excinfo.value.code() == grpc.StatusCode.INVALID_ARGUMENT, "Order is already cancelled"
532+
533+
534+
@pytest.mark.asyncio
535+
async def test_multiple_streams_different_filters(set_up: dict[str, Any]) -> None:
536+
"""Test creating multiple streams with different filters and ensure independent operation."""
537+
area_1 = DeliveryArea(code="10YDE-EON------1", code_type=EnergyMarketCodeType.EUROPE_EIC)
538+
area_2 = DeliveryArea(code="10YDE-RWENET---I", code_type=EnergyMarketCodeType.EUROPE_EIC)
539+
540+
stream_1 = await set_up["client"].stream_gridpool_orders(GRIDPOOL_ID, delivery_area=area_1)
541+
stream_2 = await set_up["client"].stream_gridpool_orders(GRIDPOOL_ID, delivery_area=area_2)
542+
543+
# Create orders in each area to see if they appear on correct streams
544+
order_1 = await create_test_order(set_up, delivery_area=area_1)
545+
order_2 = await create_test_order(set_up, delivery_area=area_2)
546+
547+
try:
548+
streamed_order_1 = await asyncio.wait_for(anext(stream_1), timeout=15)
549+
streamed_order_2 = await asyncio.wait_for(anext(stream_2), timeout=15)
550+
551+
assert streamed_order_1.order == order_1.order, "Streamed order does not match area-specific order in stream 1"
552+
assert streamed_order_2.order == order_2.order, "Streamed order does not match area-specific order in stream 2"
553+
except asyncio.TimeoutError:
554+
pytest.fail("Failed to receive streamed orders within timeout")
555+
556+
379557

380558
@pytest.fixture(scope="session")
381559
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:

src/frequenz/client/electricity_trading/_client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ class _Sentinel:
5858
PRECISION_DECIMAL_PRICE = 2
5959
PRECISION_DECIMAL_QUANTITY = 1
6060

61+
MIN_QUANTITY_MW = Decimal("0.1")
62+
MIN_PRICE = Decimal(-9999.0)
63+
MAX_PRICE = Decimal(9999.0)
64+
6165

6266
def validate_decimal_places(value: Decimal, decimal_places: int, name: str) -> None:
6367
"""
@@ -382,8 +386,14 @@ def validate_params(
382386
NotImplementedError: If the order type is not supported.
383387
"""
384388
if not isinstance(price, _Sentinel) and price is not None:
389+
if price.amount < MIN_PRICE or price.amount > MAX_PRICE:
390+
raise ValueError(f"Price must be between {MIN_PRICE} and {MAX_PRICE}.")
385391
validate_decimal_places(price.amount, PRECISION_DECIMAL_PRICE, "price")
386392
if not isinstance(quantity, _Sentinel) and quantity is not None:
393+
if quantity.mw <= 0:
394+
raise ValueError("Quantity must be strictly positive")
395+
if quantity.mw < MIN_QUANTITY_MW:
396+
raise ValueError(f"Quantity must be at least {MIN_QUANTITY_MW} MW.")
387397
validate_decimal_places(quantity.mw, PRECISION_DECIMAL_QUANTITY, "quantity")
388398
if not isinstance(stop_price, _Sentinel) and stop_price is not None:
389399
raise NotImplementedError(

0 commit comments

Comments
 (0)