Skip to content

Commit cb4f7bd

Browse files
Add integration tests for the API
Signed-off-by: camille-bouvy-frequenz <[email protected]>
1 parent 4755a86 commit cb4f7bd

File tree

1 file changed

+385
-0
lines changed

1 file changed

+385
-0
lines changed

integration_tests/test_api.py

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""System tests for Electricity Trading API."""
5+
import asyncio
6+
from datetime import datetime, timedelta, timezone
7+
from decimal import Decimal
8+
from typing import Any, Generator
9+
10+
import os
11+
import grpc
12+
import pytest
13+
14+
from frequenz.client.electricity_trading import (
15+
Client,
16+
Currency,
17+
DeliveryArea,
18+
DeliveryPeriod,
19+
EnergyMarketCodeType,
20+
MarketSide,
21+
OrderDetail,
22+
OrderState,
23+
OrderType,
24+
Power,
25+
Price,
26+
)
27+
28+
API_KEY = os.getenv("API_KEY", None)
29+
if not API_KEY:
30+
raise ValueError("API Key is not set")
31+
GRIDPOOL_ID = os.getenv("GRIDPOOL_ID", None)
32+
if not GRIDPOOL_ID:
33+
raise ValueError("Gridpool ID is not set")
34+
GRIDPOOL_ID = int(GRIDPOOL_ID)
35+
SERVER_URL = "grpc://electricity-trading-testing.api.frequenz.com:443?ssl=true"
36+
37+
38+
@pytest.fixture
39+
async def set_up() -> dict[str, Any]:
40+
"""Set up the test suite."""
41+
client = Client(
42+
server_url=SERVER_URL,
43+
auth_key=API_KEY,
44+
)
45+
46+
delivery_area = DeliveryArea(
47+
code="10YDE-EON------1", code_type=EnergyMarketCodeType.EUROPE_EIC
48+
)
49+
# Setting delivery start to the next whole hour after two hours from now
50+
delivery_start = (datetime.now(timezone.utc) + timedelta(hours=3)).replace(
51+
minute=0, second=0, microsecond=0
52+
)
53+
delivery_period = DeliveryPeriod(
54+
start=delivery_start,
55+
duration=timedelta(minutes=15),
56+
)
57+
price = Price(amount=Decimal("56"), currency=Currency.EUR)
58+
quantity = Power(mw=Decimal("0.1"))
59+
order_type = OrderType.LIMIT
60+
61+
return {
62+
"client": client,
63+
"delivery_area": delivery_area,
64+
"delivery_period": delivery_period,
65+
"price": price,
66+
"quantity": quantity,
67+
"order_type": order_type,
68+
}
69+
70+
71+
async def create_test_order(
72+
set_up: dict[str, Any],
73+
side: MarketSide = MarketSide.BUY,
74+
price: Price | None = None,
75+
delivery_period: DeliveryPeriod | None = None,
76+
) -> OrderDetail:
77+
"""Create a test order with customizable parameters."""
78+
order_price = price or set_up["price"]
79+
order_delivery_period = delivery_period or set_up["delivery_period"]
80+
order = await set_up["client"].create_gridpool_order(
81+
gridpool_id=GRIDPOOL_ID,
82+
delivery_area=set_up["delivery_area"],
83+
delivery_period=order_delivery_period,
84+
order_type=set_up["order_type"],
85+
side=side,
86+
price=order_price,
87+
quantity=set_up["quantity"],
88+
tag="api-integration-test",
89+
)
90+
return order # type: ignore
91+
92+
93+
async def create_test_trade(
94+
set_up: dict[str, Any],
95+
) -> tuple[OrderDetail, OrderDetail]:
96+
"""
97+
Create identical orders on opposite sides to try to trigger a trade.
98+
99+
Args:
100+
set_up: The setup dictionary.
101+
Returns:
102+
A tuple of the created buy and sell orders.
103+
"""
104+
# Set a different delivery period so that it is the only trade retrieved
105+
# It should also be < 9 hours from now since EPEX's intraday order book opens at 15:00
106+
delivery_start = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(
107+
minute=0, second=0, microsecond=0
108+
)
109+
delivery_period = DeliveryPeriod(
110+
start=delivery_start,
111+
duration=timedelta(minutes=15),
112+
)
113+
buy_order = await create_test_order(
114+
set_up=set_up,
115+
delivery_period=delivery_period,
116+
side=MarketSide.BUY,
117+
price=Price(amount=Decimal("33"), currency=Currency.EUR),
118+
)
119+
120+
sell_order = await create_test_order(
121+
set_up=set_up,
122+
delivery_period=delivery_period,
123+
side=MarketSide.SELL,
124+
price=Price(amount=Decimal("33"), currency=Currency.EUR),
125+
)
126+
127+
return buy_order, sell_order
128+
129+
130+
@pytest.mark.asyncio
131+
async def test_create_and_get_order(set_up: dict[str, Any]) -> None:
132+
"""Test creating a gridpool order and ensure it exists in the system."""
133+
# Create an order first
134+
order = await create_test_order(set_up)
135+
assert order is not None, "Order creation failed"
136+
137+
# Fetch order to check it exists remotely
138+
fetched_order = await set_up["client"].get_gridpool_order(
139+
GRIDPOOL_ID, order.order_id
140+
)
141+
142+
assert fetched_order.order == order.order, "Order mismatch"
143+
144+
145+
@pytest.mark.asyncio
146+
async def test_create_order_invalid_delivery_start_one_day_ago(
147+
set_up: dict[str, Any]
148+
) -> None:
149+
"""Test creating an order with a passed delivery start (one day ago)."""
150+
# Create an order with a delivery start in the past
151+
delivery_start = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
152+
minute=0, second=0, microsecond=0
153+
)
154+
delivery_period = DeliveryPeriod(
155+
start=delivery_start,
156+
duration=timedelta(minutes=15),
157+
)
158+
with pytest.raises(ValueError, match="delivery_period must be in the future"):
159+
await create_test_order(set_up, delivery_period=delivery_period)
160+
161+
162+
@pytest.mark.asyncio
163+
async def test_create_order_invalid_delivery_start_one_hour_ago(
164+
set_up: dict[str, Any]
165+
) -> None:
166+
"""Test creating an order with a passed delivery start (one hour ago)."""
167+
# Create an order with a delivery start in the past
168+
delivery_start = (datetime.now(timezone.utc) - timedelta(hours=1)).replace(
169+
minute=0, second=0, microsecond=0
170+
)
171+
delivery_period = DeliveryPeriod(
172+
start=delivery_start,
173+
duration=timedelta(minutes=15),
174+
)
175+
with pytest.raises(ValueError, match="delivery_period must be in the future"):
176+
await create_test_order(set_up, delivery_period=delivery_period)
177+
178+
179+
@pytest.mark.asyncio
180+
async def test_create_order_invalid_delivery_start_15_minutes_ago(
181+
set_up: dict[str, Any]
182+
) -> None:
183+
"""Test creating an order with a passed delivery start (15 minutes ago)."""
184+
# Create an order with a delivery start in the past
185+
delivery_start = (datetime.now(timezone.utc) - timedelta(minutes=15)).replace(
186+
minute=0, second=0, microsecond=0
187+
)
188+
delivery_period = DeliveryPeriod(
189+
start=delivery_start,
190+
duration=timedelta(minutes=15),
191+
)
192+
with pytest.raises(ValueError, match="delivery_period must be in the future"):
193+
await create_test_order(set_up, delivery_period=delivery_period)
194+
195+
196+
@pytest.mark.asyncio
197+
async def test_list_gridpool_orders(set_up: dict[str, Any]) -> None:
198+
"""Test listing gridpool orders and ensure they exist in the system."""
199+
# Create several orders
200+
created_orders_id = [(await create_test_order(set_up)).order_id for _ in range(10)]
201+
202+
# List the orders and check they are present
203+
orders = await set_up["client"].list_gridpool_orders(
204+
gridpool_id=GRIDPOOL_ID, delivery_period=set_up["delivery_period"]
205+
) # filter by delivery period to avoid fetching too many orders
206+
207+
listed_orders_id = [order.order_id for order in orders]
208+
for order_id in created_orders_id:
209+
assert order_id in listed_orders_id, f"Order ID {order_id} not found"
210+
211+
212+
@pytest.mark.asyncio
213+
async def test_update_order_price(set_up: dict[str, Any]) -> None:
214+
"""Test updating the price of an order."""
215+
# Create an order first
216+
order = await create_test_order(set_up)
217+
218+
# Update the order price and check the update was successful
219+
new_price = Price(amount=Decimal("50"), currency=Currency.EUR)
220+
updated_order = await set_up["client"].update_gridpool_order(
221+
gridpool_id=GRIDPOOL_ID, order_id=order.order_id, price=new_price
222+
)
223+
224+
assert updated_order.order.price.amount == new_price.amount, "Price update failed"
225+
fetched_order = await set_up["client"].get_gridpool_order(
226+
GRIDPOOL_ID, order.order_id
227+
)
228+
assert (
229+
fetched_order.order.price.amount == updated_order.order.price.amount
230+
), "Fetched price mismatch after update"
231+
assert (
232+
order.order.price.amount != new_price.amount
233+
), "Original price should not be the same as the updated price"
234+
235+
236+
@pytest.mark.asyncio
237+
async def test_update_order_quantity_failure(set_up: dict[str, Any]) -> None:
238+
"""Test updating the quantity of an order and ensure it fails."""
239+
# Create an order first
240+
order = await create_test_order(set_up)
241+
242+
quantity = Power(mw=Decimal("10"))
243+
244+
# Expected failure as quantity update is not supported
245+
with pytest.raises(grpc.aio.AioRpcError) as excinfo:
246+
await set_up["client"].update_gridpool_order(
247+
gridpool_id=GRIDPOOL_ID, order_id=order.order_id, quantity=quantity
248+
)
249+
250+
assert str(excinfo.value.details()) == "Updating 'quantity' is not allowed."
251+
assert (
252+
excinfo.value.code() == grpc.StatusCode.INVALID_ARGUMENT
253+
), "Expected INVALID_ARGUMENT error"
254+
255+
256+
@pytest.mark.asyncio
257+
async def test_cancel_order(set_up: dict[str, Any]) -> None:
258+
"""Test cancelling an order."""
259+
# Create the order to be cancelled
260+
order = await create_test_order(set_up)
261+
262+
# Cancel the created order and ensure it's cancelled
263+
cancelled_order = await set_up["client"].cancel_gridpool_order(
264+
GRIDPOOL_ID, order.order_id
265+
)
266+
assert cancelled_order.order_id == order.order_id, "Order cancellation failed"
267+
268+
fetched_order = await set_up["client"].get_gridpool_order(
269+
GRIDPOOL_ID, order.order_id
270+
)
271+
assert (
272+
fetched_order.state_detail.state == OrderState.CANCELED
273+
), "Order state should be CANCELED"
274+
275+
276+
@pytest.mark.asyncio
277+
async def test_update_cancelled_order_failure(set_up: dict[str, Any]) -> None:
278+
"""Test updating a cancelled order and ensure it fails."""
279+
# Create an order first
280+
order = await create_test_order(set_up)
281+
282+
# Cancel the created order
283+
await set_up["client"].cancel_gridpool_order(GRIDPOOL_ID, order.order_id)
284+
285+
# Expected failure as cancelled order cannot be updated
286+
with pytest.raises(grpc.aio.AioRpcError) as excinfo:
287+
await set_up["client"].update_gridpool_order(
288+
gridpool_id=GRIDPOOL_ID, order_id=order.order_id, price=set_up["price"]
289+
)
290+
assert (
291+
excinfo.value.code() == grpc.StatusCode.INVALID_ARGUMENT
292+
), "Expected INVALID_ARGUMENT error"
293+
294+
295+
@pytest.mark.asyncio
296+
async def test_cancel_all_orders(set_up: dict[str, Any]) -> None:
297+
"""Test cancelling all orders."""
298+
# Create multiple orders
299+
for _ in range(10):
300+
await create_test_order(set_up)
301+
302+
# Cancel all orders and check that did indeed get cancelled
303+
await set_up["client"].cancel_all_gridpool_orders(GRIDPOOL_ID)
304+
305+
orders = await set_up["client"].list_gridpool_orders(gridpool_id=GRIDPOOL_ID)
306+
307+
for order in orders:
308+
assert (
309+
order.state_detail.state == OrderState.CANCELED
310+
), f"Order {order.order_id} not canceled"
311+
312+
313+
@pytest.mark.asyncio
314+
async def test_list_gridpool_trades(set_up: dict[str, Any]) -> None:
315+
"""Test listing gridpool trades."""
316+
buy_order, sell_order = await create_test_trade(set_up)
317+
trades = await set_up["client"].list_gridpool_trades(
318+
GRIDPOOL_ID,
319+
delivery_period=buy_order.order.delivery_period,
320+
)
321+
assert len(trades) >= 1
322+
323+
324+
@pytest.mark.asyncio
325+
async def test_list_public_trades(set_up: dict[str, Any]) -> None:
326+
"""Test listing public trades."""
327+
public_trades = await set_up["client"].list_public_trades(
328+
delivery_period=set_up["delivery_period"],
329+
max_nr_trades=10,
330+
)
331+
assert len(public_trades) >= 0
332+
333+
334+
@pytest.mark.asyncio
335+
async def test_stream_gridpool_orders(set_up: dict[str, Any]) -> None:
336+
"""Test streaming gridpool orders."""
337+
stream = await set_up["client"].stream_gridpool_orders(GRIDPOOL_ID)
338+
test_order = await create_test_order(set_up)
339+
340+
try:
341+
# Stream trades with a 15-second timeout to avoid indefinite hanging
342+
streamed_order = await asyncio.wait_for(anext(stream), timeout=15)
343+
assert streamed_order is not None, "Failed to receive streamed order."
344+
assert (
345+
streamed_order.order == test_order.order
346+
), "Streamed order does not match created order"
347+
except asyncio.TimeoutError:
348+
pytest.fail("Streaming timed out, no order received in 15 seconds")
349+
350+
351+
@pytest.mark.asyncio
352+
async def test_stream_public_trades(set_up: dict[str, Any]) -> None:
353+
"""Test stream public trades."""
354+
stream = await set_up["client"].stream_public_trades()
355+
356+
try:
357+
# Stream trades with a 15-second timeout to avoid indefinite hanging
358+
streamed_trade = await asyncio.wait_for(anext(stream), timeout=15)
359+
assert streamed_trade is not None, "Failed to receive streamed trade"
360+
except asyncio.TimeoutError:
361+
pytest.fail("Streaming timed out, no trade received in 15 seconds")
362+
363+
364+
@pytest.mark.asyncio
365+
async def test_stream_gridpool_trades(set_up: dict[str, Any]) -> None:
366+
"""Test stream gridpool trades."""
367+
stream = await set_up["client"].stream_gridpool_trades(GRIDPOOL_ID)
368+
369+
# Create identical orders on opposite sides to try to trigger a trade
370+
await create_test_trade(set_up)
371+
372+
try:
373+
# Stream trades with a 15-second timeout to avoid indefinite hanging
374+
streamed_trade = await asyncio.wait_for(anext(stream), timeout=15)
375+
assert streamed_trade is not None, "Failed to receive streamed trade"
376+
except asyncio.TimeoutError:
377+
pytest.fail("Streaming timed out, no trade received in 15 seconds")
378+
379+
380+
@pytest.fixture(scope="session")
381+
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
382+
"""Create an event loop for the tests."""
383+
loop = asyncio.get_event_loop_policy().new_event_loop()
384+
yield loop
385+
loop.close()

0 commit comments

Comments
 (0)