Add Epic Games Store integration (#104725)

* Add Epic Games Store integration

Squashed commit of the following PR: #81167

* Bump epicstore-api to 0.1.7 as it handle better error 1004

Thanks to d7469f7c99

* Use extra_state_attributes instead of overriding state_attributes

* Review: change how config_flow.validate_input is handled

* Use LanguageSelector and rename locale to language

* Review: init-better use of hass.data.setdefault

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Review: don't need to update at init

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Revert "Review: don't need to update at init" --> not working otherwise

This reverts commit 1445a87c8e9b7247f1c9835bf2e2d7297dd02586.

* Review: fix config_flow.validate_input/retactor following lib bump

* review: merge async_update function with event property

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* hassfest

* Fix duplicates data from applied comment review 5035055

* review: thanks to 5035055 async_add_entities update_before_add param is not required anymore

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Fix Christmas special "Holiday sale" case

* gen_requirements_all

* Use CONF_LANGUAGE from HA const

* Move CalendarType to const

* manifest: integration_type -> service

Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com>

* calendar: remove date start/end assert

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* const: rename SUPPORTED_LANGUAGES

* hassfest

* config: Move to ConfigFlowResult

* coordinator: main file comment

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* ruff & hassfest

* review: do not guess country

* Add @hacf-fr as codeowner

* review: remove games extra_attrs
Was dropped somehow:
- 73c20f34803b0a0ec242bf0740494f17a68f6f59 review: move games extra_attrs to data service
- other commit that removed the service part

* review: remove unused error class
was removed:
- 040cf945bb5346b6d42b3782b5061a13fb7b1f6b

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Quentame 2024-04-22 09:54:47 +02:00 committed by GitHub
parent 66ea528e94
commit f927b27ed4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 5125 additions and 0 deletions

View file

@ -361,6 +361,8 @@ omit =
homeassistant/components/environment_canada/weather.py
homeassistant/components/envisalink/*
homeassistant/components/ephember/climate.py
homeassistant/components/epic_games_store/__init__.py
homeassistant/components/epic_games_store/coordinator.py
homeassistant/components/epion/__init__.py
homeassistant/components/epion/coordinator.py
homeassistant/components/epion/sensor.py

View file

@ -398,6 +398,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50
/homeassistant/components/epic_games_store/ @hacf-fr @Quentame
/tests/components/epic_games_store/ @hacf-fr @Quentame
/homeassistant/components/epion/ @lhgravendeel
/tests/components/epion/ @lhgravendeel
/homeassistant/components/epson/ @pszafer

View file

@ -0,0 +1,35 @@
"""The Epic Games Store integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import EGSCalendarUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.CALENDAR,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Epic Games Store from a config entry."""
coordinator = EGSCalendarUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,97 @@
"""Calendar platform for a Epic Games Store."""
from __future__ import annotations
from collections import namedtuple
from datetime import datetime
from typing import Any
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, CalendarType
from .coordinator import EGSCalendarUpdateCoordinator
DateRange = namedtuple("DateRange", ["start", "end"])
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the local calendar platform."""
coordinator: EGSCalendarUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
EGSCalendar(coordinator, entry.entry_id, CalendarType.FREE),
EGSCalendar(coordinator, entry.entry_id, CalendarType.DISCOUNT),
]
async_add_entities(entities)
class EGSCalendar(CoordinatorEntity[EGSCalendarUpdateCoordinator], CalendarEntity):
"""A calendar entity by Epic Games Store."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: EGSCalendarUpdateCoordinator,
config_entry_id: str,
cal_type: CalendarType,
) -> None:
"""Initialize EGSCalendar."""
super().__init__(coordinator)
self._cal_type = cal_type
self._attr_translation_key = f"{cal_type}_games"
self._attr_unique_id = f"{config_entry_id}-{cal_type}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, config_entry_id)},
manufacturer="Epic Games Store",
name="Epic Games Store",
)
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
if event := self.coordinator.data[self._cal_type]:
return _get_calendar_event(event[0])
return None
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
events = filter(
lambda game: _are_date_range_overlapping(
DateRange(start=game["discount_start_at"], end=game["discount_end_at"]),
DateRange(start=start_date, end=end_date),
),
self.coordinator.data[self._cal_type],
)
return [_get_calendar_event(event) for event in events]
def _get_calendar_event(event: dict[str, Any]) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
return CalendarEvent(
summary=event["title"],
start=event["discount_start_at"],
end=event["discount_end_at"],
description=f"{event['description']}\n\n{event['url']}",
)
def _are_date_range_overlapping(range1: DateRange, range2: DateRange) -> bool:
"""Return a CalendarEvent from an API event."""
latest_start = max(range1.start, range2.start)
earliest_end = min(range1.end, range2.end)
delta = (earliest_end - latest_start).days + 1
overlap = max(0, delta)
return overlap > 0

View file

@ -0,0 +1,96 @@
"""Config flow for Epic Games Store integration."""
from __future__ import annotations
import logging
from typing import Any
from epicstore_api import EpicGamesStoreAPI
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.selector import (
CountrySelector,
LanguageSelector,
LanguageSelectorConfig,
)
from .const import DOMAIN, SUPPORTED_LANGUAGES
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LANGUAGE): LanguageSelector(
LanguageSelectorConfig(languages=SUPPORTED_LANGUAGES)
),
vol.Required(CONF_COUNTRY): CountrySelector(),
}
)
def get_default_language(hass: HomeAssistant) -> str | None:
"""Get default language code based on Home Assistant config."""
language_code = f"{hass.config.language}-{hass.config.country}"
if language_code in SUPPORTED_LANGUAGES:
return language_code
if hass.config.language in SUPPORTED_LANGUAGES:
return hass.config.language
return None
async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> None:
"""Validate the user input allows us to connect."""
api = EpicGamesStoreAPI(user_input[CONF_LANGUAGE], user_input[CONF_COUNTRY])
data = await hass.async_add_executor_job(api.get_free_games)
if data.get("errors"):
_LOGGER.warning(data["errors"])
assert data["data"]["Catalog"]["searchStore"]["elements"]
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Epic Games Store."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
data_schema = self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
user_input
or {
CONF_LANGUAGE: get_default_language(self.hass),
CONF_COUNTRY: self.hass.config.country,
},
)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=data_schema)
await self.async_set_unique_id(
f"freegames-{user_input[CONF_LANGUAGE]}-{user_input[CONF_COUNTRY]}"
)
self._abort_if_unique_id_configured()
errors = {}
try:
await validate_input(self.hass, user_input)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=f"Epic Games Store - Free Games ({user_input[CONF_LANGUAGE]}-{user_input[CONF_COUNTRY]})",
data=user_input,
)
return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)

View file

@ -0,0 +1,31 @@
"""Constants for the Epic Games Store integration."""
from enum import StrEnum
DOMAIN = "epic_games_store"
SUPPORTED_LANGUAGES = [
"ar",
"de",
"en-US",
"es-ES",
"es-MX",
"fr",
"it",
"ja",
"ko",
"pl",
"pt-BR",
"ru",
"th",
"tr",
"zh-CN",
"zh-Hant",
]
class CalendarType(StrEnum):
"""Calendar types."""
FREE = "free"
DISCOUNT = "discount"

View file

@ -0,0 +1,81 @@
"""The Epic Games Store integration data coordinator."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from epicstore_api import EpicGamesStoreAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, CalendarType
from .helper import format_game_data
SCAN_INTERVAL = timedelta(days=1)
_LOGGER = logging.getLogger(__name__)
class EGSCalendarUpdateCoordinator(
DataUpdateCoordinator[dict[str, list[dict[str, Any]]]]
):
"""Class to manage fetching data from the Epic Game Store."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize."""
self._api = EpicGamesStoreAPI(
entry.data[CONF_LANGUAGE],
entry.data[CONF_COUNTRY],
)
self.language = entry.data[CONF_LANGUAGE]
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> dict[str, list[dict[str, Any]]]:
"""Update data via library."""
raw_data = await self.hass.async_add_executor_job(self._api.get_free_games)
_LOGGER.debug(raw_data)
data = raw_data["data"]["Catalog"]["searchStore"]["elements"]
discount_games = filter(
lambda game: game.get("promotions")
and (
# Current discount(s)
game["promotions"]["promotionalOffers"]
or
# Upcoming discount(s)
game["promotions"]["upcomingPromotionalOffers"]
),
data,
)
return_data: dict[str, list[dict[str, Any]]] = {
CalendarType.DISCOUNT: [],
CalendarType.FREE: [],
}
for discount_game in discount_games:
game = format_game_data(discount_game, self.language)
if game["discount_type"]:
return_data[game["discount_type"]].append(game)
return_data[CalendarType.DISCOUNT] = sorted(
return_data[CalendarType.DISCOUNT],
key=lambda game: game["discount_start_at"],
)
return_data[CalendarType.FREE] = sorted(
return_data[CalendarType.FREE], key=lambda game: game["discount_start_at"]
)
_LOGGER.debug(return_data)
return return_data

View file

@ -0,0 +1,92 @@
"""Helper for Epic Games Store."""
import contextlib
from typing import Any
from homeassistant.util import dt as dt_util
def format_game_data(raw_game_data: dict[str, Any], language: str) -> dict[str, Any]:
"""Format raw API game data for Home Assistant users."""
img_portrait = None
img_landscape = None
for image in raw_game_data["keyImages"]:
if image["type"] == "OfferImageTall":
img_portrait = image["url"]
if image["type"] == "OfferImageWide":
img_landscape = image["url"]
current_promotions = raw_game_data["promotions"]["promotionalOffers"]
upcoming_promotions = raw_game_data["promotions"]["upcomingPromotionalOffers"]
promotion_data = {}
if (
current_promotions
and raw_game_data["price"]["totalPrice"]["discountPrice"] == 0
):
promotion_data = current_promotions[0]["promotionalOffers"][0]
else:
promotion_data = (current_promotions or upcoming_promotions)[0][
"promotionalOffers"
][0]
return {
"title": raw_game_data["title"].replace("\xa0", " "),
"description": raw_game_data["description"].strip().replace("\xa0", " "),
"released_at": dt_util.parse_datetime(raw_game_data["effectiveDate"]),
"original_price": raw_game_data["price"]["totalPrice"]["fmtPrice"][
"originalPrice"
].replace("\xa0", " "),
"publisher": raw_game_data["seller"]["name"],
"url": get_game_url(raw_game_data, language),
"img_portrait": img_portrait,
"img_landscape": img_landscape,
"discount_type": ("free" if is_free_game(raw_game_data) else "discount")
if promotion_data
else None,
"discount_start_at": dt_util.parse_datetime(promotion_data["startDate"])
if promotion_data
else None,
"discount_end_at": dt_util.parse_datetime(promotion_data["endDate"])
if promotion_data
else None,
}
def get_game_url(raw_game_data: dict[str, Any], language: str) -> str:
"""Format raw API game data for Home Assistant users."""
url_bundle_or_product = "bundles" if raw_game_data["offerType"] == "BUNDLE" else "p"
url_slug: str | None = None
try:
url_slug = raw_game_data["offerMappings"][0]["pageSlug"]
except Exception: # pylint: disable=broad-except
with contextlib.suppress(Exception):
url_slug = raw_game_data["catalogNs"]["mappings"][0]["pageSlug"]
if not url_slug:
url_slug = raw_game_data["urlSlug"]
return f"https://store.epicgames.com/{language}/{url_bundle_or_product}/{url_slug}"
def is_free_game(game: dict[str, Any]) -> bool:
"""Return if the game is free or will be free."""
return (
# Current free game(s)
game["promotions"]["promotionalOffers"]
and game["promotions"]["promotionalOffers"][0]["promotionalOffers"][0][
"discountSetting"
]["discountPercentage"]
== 0
and
# Checking current price, maybe not necessary
game["price"]["totalPrice"]["discountPrice"] == 0
) or (
# Upcoming free game(s)
game["promotions"]["upcomingPromotionalOffers"]
and game["promotions"]["upcomingPromotionalOffers"][0]["promotionalOffers"][0][
"discountSetting"
]["discountPercentage"]
== 0
)

View file

@ -0,0 +1,10 @@
{
"domain": "epic_games_store",
"name": "Epic Games Store",
"codeowners": ["@hacf-fr", "@Quentame"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/epic_games_store",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["epicstore-api==0.1.7"]
}

View file

@ -0,0 +1,38 @@
{
"config": {
"step": {
"user": {
"data": {
"language": "Language",
"country": "Country"
}
}
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"entity": {
"calendar": {
"free_games": {
"name": "Free games",
"state_attributes": {
"games": {
"name": "Games"
}
}
},
"discount_games": {
"name": "Discount games",
"state_attributes": {
"games": {
"name": "[%key:component::epic_games_store::entity::calendar::free_games::state_attributes::games::name%]"
}
}
}
}
}
}

View file

@ -152,6 +152,7 @@ FLOWS = {
"enocean",
"enphase_envoy",
"environment_canada",
"epic_games_store",
"epion",
"epson",
"eq3btsmart",

View file

@ -1649,6 +1649,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
"epic_games_store": {
"name": "Epic Games Store",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"epion": {
"name": "Epion",
"integration_type": "hub",

View file

@ -806,6 +806,9 @@ env-canada==0.6.0
# homeassistant.components.season
ephem==4.1.5
# homeassistant.components.epic_games_store
epicstore-api==0.1.7
# homeassistant.components.epion
epion==0.0.3

View file

@ -660,6 +660,9 @@ env-canada==0.6.0
# homeassistant.components.season
ephem==4.1.5
# homeassistant.components.epic_games_store
epicstore-api==0.1.7
# homeassistant.components.epion
epion==0.0.3

View file

@ -0,0 +1 @@
"""Tests for the Epic Games Store integration."""

View file

@ -0,0 +1,31 @@
"""Common methods used across tests for Epic Games Store."""
from unittest.mock import patch
from homeassistant.components.epic_games_store.const import DOMAIN
from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .const import MOCK_COUNTRY, MOCK_LANGUAGE
from tests.common import MockConfigEntry
async def setup_platform(hass: HomeAssistant, platform: str) -> MockConfigEntry:
"""Set up the Epic Games Store platform."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_LANGUAGE: MOCK_LANGUAGE,
CONF_COUNTRY: MOCK_COUNTRY,
},
unique_id=f"freegames-{MOCK_LANGUAGE}-{MOCK_COUNTRY}",
)
mock_entry.add_to_hass(hass)
with patch("homeassistant.components.epic_games_store.PLATFORMS", [platform]):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
return mock_entry

View file

@ -0,0 +1,44 @@
"""Define fixtures for Epic Games Store tests."""
from unittest.mock import Mock, patch
import pytest
from .const import (
DATA_ERROR_ATTRIBUTE_NOT_FOUND,
DATA_FREE_GAMES,
DATA_FREE_GAMES_CHRISTMAS_SPECIAL,
)
@pytest.fixture(name="service_multiple")
def mock_service_multiple():
"""Mock a successful service with multiple free & discount games."""
with patch(
"homeassistant.components.epic_games_store.coordinator.EpicGamesStoreAPI"
) as service_mock:
instance = service_mock.return_value
instance.get_free_games = Mock(return_value=DATA_FREE_GAMES)
yield service_mock
@pytest.fixture(name="service_christmas_special")
def mock_service_christmas_special():
"""Mock a successful service with Christmas special case."""
with patch(
"homeassistant.components.epic_games_store.coordinator.EpicGamesStoreAPI"
) as service_mock:
instance = service_mock.return_value
instance.get_free_games = Mock(return_value=DATA_FREE_GAMES_CHRISTMAS_SPECIAL)
yield service_mock
@pytest.fixture(name="service_attribute_not_found")
def mock_service_attribute_not_found():
"""Mock a successful service returning a not found attribute error with free & discount games."""
with patch(
"homeassistant.components.epic_games_store.coordinator.EpicGamesStoreAPI"
) as service_mock:
instance = service_mock.return_value
instance.get_free_games = Mock(return_value=DATA_ERROR_ATTRIBUTE_NOT_FOUND)
yield service_mock

View file

@ -0,0 +1,25 @@
"""Test constants."""
from homeassistant.components.epic_games_store.const import DOMAIN
from tests.common import load_json_object_fixture
MOCK_LANGUAGE = "fr"
MOCK_COUNTRY = "FR"
DATA_ERROR_ATTRIBUTE_NOT_FOUND = load_json_object_fixture(
"error_1004_attribute_not_found.json", DOMAIN
)
DATA_ERROR_WRONG_COUNTRY = load_json_object_fixture(
"error_5222_wrong_country.json", DOMAIN
)
# free games
DATA_FREE_GAMES = load_json_object_fixture("free_games.json", DOMAIN)
DATA_FREE_GAMES_ONE = load_json_object_fixture("free_games_one.json", DOMAIN)
DATA_FREE_GAMES_CHRISTMAS_SPECIAL = load_json_object_fixture(
"free_games_christmas_special.json", DOMAIN
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
{
"errors": [
{
"message": "CatalogQuery/searchStore: Request failed with status code 400",
"locations": [
{
"line": 18,
"column": 9
}
],
"correlationId": "e10ad58e-a4f9-4097-af5d-cafdbe0d8bbd",
"serviceResponse": "{\"errorCode\":\"errors.com.epicgames.catalog.invalid_country_code\",\"errorMessage\":\"Sorry the value you entered: en-US, does not appear to be a valid ISO country code.\",\"messageVars\":[\"en-US\"],\"numericErrorCode\":5222,\"originatingService\":\"com.epicgames.catalog.public\",\"intent\":\"prod\",\"errorStatus\":400}",
"stack": null,
"path": ["Catalog", "searchStore"]
}
],
"data": {
"Catalog": {
"searchStore": null
}
},
"extensions": {}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,253 @@
{
"data": {
"Catalog": {
"searchStore": {
"elements": [
{
"title": "Cursed to Golf",
"id": "0e4551e4ae65492b88009f8a4e41d778",
"namespace": "d5241c76f178492ea1540fce45616757",
"description": "Cursed to Golf",
"effectiveDate": "2023-12-27T16:00:00.000Z",
"offerType": "OTHERS",
"expiryDate": "2023-12-28T16:00:00.000Z",
"viewableDate": "2023-12-26T15:25:00.000Z",
"status": "ACTIVE",
"isCodeRedemptionOnly": true,
"keyImages": [
{
"type": "DieselStoreFrontWide",
"url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/Free-Game-9_1920x1080-418a8fa10dd305bb2a219a7ec869c5ef"
},
{
"type": "VaultClosed",
"url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/Free-Game-9-teaser_1920x1080-e71ae0041736db5ac259a355cb301116"
}
],
"seller": {
"id": "o-ufmrk5furrrxgsp5tdngefzt5rxdcn",
"name": "Epic Dev Test Account"
},
"productSlug": "cursed-to-golf-a6bc22",
"urlSlug": "mysterygame-9",
"url": null,
"items": [
{
"id": "8341d7c7e4534db7848cc428aa4cbe5a",
"namespace": "d5241c76f178492ea1540fce45616757"
}
],
"customAttributes": [
{
"key": "com.epicgames.app.freegames.vault.close",
"value": "[]"
},
{
"key": "com.epicgames.app.blacklist",
"value": "[]"
},
{
"key": "com.epicgames.app.freegames.vault.slug",
"value": "sales-and-specials/holiday-sale"
},
{
"key": "com.epicgames.app.freegames.vault.open",
"value": "[]"
},
{
"key": "com.epicgames.app.productSlug",
"value": "cursed-to-golf-a6bc22"
}
],
"categories": [
{
"path": "freegames/vaulted"
},
{
"path": "freegames"
},
{
"path": "games"
},
{
"path": "applications"
}
],
"tags": [],
"catalogNs": {
"mappings": []
},
"offerMappings": [],
"price": {
"totalPrice": {
"discountPrice": 0,
"originalPrice": 0,
"voucherDiscount": 0,
"discount": 0,
"currencyCode": "EUR",
"currencyInfo": {
"decimals": 2
},
"fmtPrice": {
"originalPrice": "0",
"discountPrice": "0",
"intermediatePrice": "0"
}
},
"lineOffers": [
{
"appliedRules": []
}
]
},
"promotions": {
"promotionalOffers": [
{
"promotionalOffers": [
{
"startDate": "2023-12-27T16:00:00.000Z",
"endDate": "2023-12-28T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 0
}
},
{
"startDate": "2023-12-27T16:00:00.000Z",
"endDate": "2023-12-28T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 0
}
}
]
}
],
"upcomingPromotionalOffers": []
}
},
{
"title": "Mystery Game Day 10",
"id": "a8c3537a579943a688e3bd355ae36209",
"namespace": "d5241c76f178492ea1540fce45616757",
"description": "Mystery Game Day 10",
"effectiveDate": "2099-01-01T16:00:00.000Z",
"offerType": "OTHERS",
"expiryDate": null,
"viewableDate": "2023-12-27T15:25:00.000Z",
"status": "ACTIVE",
"isCodeRedemptionOnly": true,
"keyImages": [
{
"type": "VaultClosed",
"url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/Free-Game-10-teaser_1920x1080-3ea48042a44263bf1a0a59c725b6d95b"
},
{
"type": "DieselStoreFrontWide",
"url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/Free-Game-10-teaser_1920x1080-3ea48042a44263bf1a0a59c725b6d95b"
}
],
"seller": {
"id": "o-ufmrk5furrrxgsp5tdngefzt5rxdcn",
"name": "Epic Dev Test Account"
},
"productSlug": "[]",
"urlSlug": "mysterygame-10",
"url": null,
"items": [
{
"id": "8341d7c7e4534db7848cc428aa4cbe5a",
"namespace": "d5241c76f178492ea1540fce45616757"
}
],
"customAttributes": [
{
"key": "com.epicgames.app.freegames.vault.close",
"value": "[]"
},
{
"key": "com.epicgames.app.blacklist",
"value": "[]"
},
{
"key": "com.epicgames.app.freegames.vault.slug",
"value": "sales-and-specials/holiday-sale"
},
{
"key": "com.epicgames.app.freegames.vault.open",
"value": "[]"
},
{
"key": "com.epicgames.app.productSlug",
"value": "[]"
}
],
"categories": [
{
"path": "freegames/vaulted"
},
{
"path": "freegames"
},
{
"path": "games"
},
{
"path": "applications"
}
],
"tags": [],
"catalogNs": {
"mappings": []
},
"offerMappings": [],
"price": {
"totalPrice": {
"discountPrice": 0,
"originalPrice": 0,
"voucherDiscount": 0,
"discount": 0,
"currencyCode": "EUR",
"currencyInfo": {
"decimals": 2
},
"fmtPrice": {
"originalPrice": "0",
"discountPrice": "0",
"intermediatePrice": "0"
}
},
"lineOffers": [
{
"appliedRules": []
}
]
},
"promotions": {
"promotionalOffers": [],
"upcomingPromotionalOffers": [
{
"promotionalOffers": [
{
"startDate": "2023-12-28T16:00:00.000Z",
"endDate": "2023-12-29T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 0
}
}
]
}
]
}
}
],
"paging": {
"count": 1000,
"total": 2
}
}
}
},
"extensions": {}
}

View file

@ -0,0 +1,658 @@
{
"data": {
"Catalog": {
"searchStore": {
"elements": [
{
"title": "Borderlands 3 Season Pass",
"id": "c3913a91e07b43cfbbbcfd8244c86dcc",
"namespace": "catnip",
"description": "Prolongez votre aventure dans Borderlands\u00a03 avec le Season Pass, regroupant des \u00e9l\u00e9ments cosm\u00e9tiques exclusifs et quatre histoires additionnelles, pour encore plus de missions et de d\u00e9fis\u00a0!",
"effectiveDate": "2019-09-11T12:00:00.000Z",
"offerType": "DLC",
"expiryDate": null,
"status": "ACTIVE",
"isCodeRedemptionOnly": false,
"keyImages": [
{
"type": "OfferImageWide",
"url": "https://cdn1.epicgames.com/offer/catnip/Diesel_productv2_borderlands-3_season-pass_BL3_SEASONPASS_Hero-3840x2160-4411e63a005a43811a2bc516ae7ec584598fd4aa-3840x2160-b8988ebb0f3d9159671e8968af991f30_3840x2160-b8988ebb0f3d9159671e8968af991f30"
},
{
"type": "OfferImageTall",
"url": "https://cdn1.epicgames.com/offer/catnip/2KGMKT_BL3_Season_Pass_EGS_1200x1600_1200x1600-a7438a079c5576d328a74b9121278075"
},
{
"type": "CodeRedemption_340x440",
"url": "https://cdn1.epicgames.com/offer/catnip/2KGMKT_BL3_Season_Pass_EGS_1200x1600_1200x1600-a7438a079c5576d328a74b9121278075"
},
{
"type": "Thumbnail",
"url": "https://cdn1.epicgames.com/offer/catnip/2KGMKT_BL3_Season_Pass_EGS_1200x1600_1200x1600-a7438a079c5576d328a74b9121278075"
}
],
"seller": {
"id": "o-37m6jbj5wcvrcvm4wusv7nazdfvbjk",
"name": "2K Games, Inc."
},
"productSlug": "borderlands-3/season-pass",
"urlSlug": "borderlands-3--season-pass",
"url": null,
"items": [
{
"id": "e9fdc1a9f47b4a5e8e63841c15de2b12",
"namespace": "catnip"
},
{
"id": "fbc46bb6056940d2847ee1e80037a9af",
"namespace": "catnip"
},
{
"id": "ff8e1152ddf742b68f9ac0cecd378917",
"namespace": "catnip"
},
{
"id": "939e660825764e208938ab4f26b4da56",
"namespace": "catnip"
},
{
"id": "4c43a9a691114ccd91c1884ab18f4e27",
"namespace": "catnip"
},
{
"id": "3a6a3f9b351b4b599808df3267669b83",
"namespace": "catnip"
},
{
"id": "ab030a9f53f3428fb2baf2ddbb0bb5ac",
"namespace": "catnip"
},
{
"id": "ff96eef22b0e4c498e8ed80ac0030325",
"namespace": "catnip"
},
{
"id": "5021e93a73374d6db1c1ce6c92234f8f",
"namespace": "catnip"
},
{
"id": "9c0b1eb3265340678dff0fcb106402b1",
"namespace": "catnip"
},
{
"id": "8c826db6e14f44aeac8816e1bd593632",
"namespace": "catnip"
}
],
"customAttributes": [
{
"key": "com.epicgames.app.blacklist",
"value": "SA"
},
{
"key": "publisherName",
"value": "2K"
},
{
"key": "developerName",
"value": "Gearbox Software"
},
{
"key": "com.epicgames.app.productSlug",
"value": "borderlands-3/season-pass"
}
],
"categories": [
{
"path": "addons"
},
{
"path": "freegames"
},
{
"path": "addons/durable"
},
{
"path": "applications"
}
],
"tags": [
{
"id": "1264"
},
{
"id": "16004"
},
{
"id": "14869"
},
{
"id": "26789"
},
{
"id": "1367"
},
{
"id": "1370"
},
{
"id": "9547"
},
{
"id": "9549"
},
{
"id": "1294"
}
],
"catalogNs": {
"mappings": [
{
"pageSlug": "borderlands-3",
"pageType": "productHome"
}
]
},
"offerMappings": [
{
"pageSlug": "borderlands-3--season-pass",
"pageType": "addon--cms-hybrid"
}
],
"price": {
"totalPrice": {
"discountPrice": 4999,
"originalPrice": 4999,
"voucherDiscount": 0,
"discount": 0,
"currencyCode": "EUR",
"currencyInfo": {
"decimals": 2
},
"fmtPrice": {
"originalPrice": "49,99\u00a0\u20ac",
"discountPrice": "49,99\u00a0\u20ac",
"intermediatePrice": "49,99\u00a0\u20ac"
}
},
"lineOffers": [
{
"appliedRules": []
}
]
},
"promotions": {
"promotionalOffers": [],
"upcomingPromotionalOffers": [
{
"promotionalOffers": [
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 30
}
},
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 25
}
},
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 25
}
},
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 30
}
}
]
}
]
}
},
{
"title": "Call of the Sea",
"id": "92da5d8d918543b6b408e36d9af81765",
"namespace": "5e427319eea1401ab20c6cd78a4163c4",
"description": "Call of the Sea is an otherworldly tale of mystery and love set in the 1930s South Pacific. Explore a lush island paradise, solve puzzles and unlock secrets in the hunt for your husband\u2019s missing expedition.",
"effectiveDate": "2022-02-17T15:00:00.000Z",
"offerType": "BASE_GAME",
"expiryDate": null,
"status": "ACTIVE",
"isCodeRedemptionOnly": false,
"keyImages": [
{
"type": "DieselStoreFrontWide",
"url": "https://cdn1.epicgames.com/salesEvent/salesEvent/EGS_CalloftheSea_OutoftheBlue_S1_2560x1440-204699c6410deef9c18be0ee392f8335"
},
{
"type": "DieselStoreFrontTall",
"url": "https://cdn1.epicgames.com/salesEvent/salesEvent/EGS_CalloftheSea_OutoftheBlue_S2_1200x1600-db63acf0c479c185e0ef8f8e73c8f0d8"
},
{
"type": "OfferImageWide",
"url": "https://cdn1.epicgames.com/salesEvent/salesEvent/EGS_CalloftheSea_OutoftheBlue_S5_1920x1080-7b22dfebdd9fcdde6e526c5dc4c16eb1"
},
{
"type": "OfferImageTall",
"url": "https://cdn1.epicgames.com/salesEvent/salesEvent/EGS_CalloftheSea_OutoftheBlue_S2_1200x1600-db63acf0c479c185e0ef8f8e73c8f0d8"
},
{
"type": "CodeRedemption_340x440",
"url": "https://cdn1.epicgames.com/salesEvent/salesEvent/EGS_CalloftheSea_OutoftheBlue_S2_1200x1600-db63acf0c479c185e0ef8f8e73c8f0d8"
},
{
"type": "Thumbnail",
"url": "https://cdn1.epicgames.com/salesEvent/salesEvent/EGS_CalloftheSea_OutoftheBlue_S2_1200x1600-db63acf0c479c185e0ef8f8e73c8f0d8"
}
],
"seller": {
"id": "o-fay4ghw9hhamujs53rfhy83ffexb7k",
"name": "Raw Fury"
},
"productSlug": "call-of-the-sea",
"urlSlug": "call-of-the-sea",
"url": null,
"items": [
{
"id": "cbc9c76c4bfc4bc6b28abb3afbcbf07a",
"namespace": "5e427319eea1401ab20c6cd78a4163c4"
}
],
"customAttributes": [
{
"key": "com.epicgames.app.productSlug",
"value": "call-of-the-sea"
}
],
"categories": [
{
"path": "freegames"
},
{
"path": "games"
},
{
"path": "games/edition"
},
{
"path": "games/edition/base"
},
{
"path": "applications"
}
],
"tags": [
{
"id": "1296"
},
{
"id": "1298"
},
{
"id": "21894"
},
{
"id": "1370"
},
{
"id": "9547"
},
{
"id": "1117"
}
],
"catalogNs": {
"mappings": [
{
"pageSlug": "call-of-the-sea",
"pageType": "productHome"
}
]
},
"offerMappings": [],
"price": {
"totalPrice": {
"discountPrice": 1999,
"originalPrice": 1999,
"voucherDiscount": 0,
"discount": 0,
"currencyCode": "EUR",
"currencyInfo": {
"decimals": 2
},
"fmtPrice": {
"originalPrice": "19,99\u00a0\u20ac",
"discountPrice": "19,99\u00a0\u20ac",
"intermediatePrice": "19,99\u00a0\u20ac"
}
},
"lineOffers": [
{
"appliedRules": []
}
]
},
"promotions": {
"promotionalOffers": [],
"upcomingPromotionalOffers": [
{
"promotionalOffers": [
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 60
}
},
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 0
}
},
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 60
}
}
]
}
]
}
},
{
"title": "Rise of Industry",
"id": "c04a2ab8ff4442cba0a41fb83453e701",
"namespace": "9f101e25b1a9427a9e6971d2b21c5f82",
"description": "Mettez vos comp\u00e9tences entrepreneuriales \u00e0 l'\u00e9preuve en cr\u00e9ant et en optimisant des cha\u00eenes de production complexes tout en gardant un \u0153il sur les r\u00e9sultats financiers. \u00c0 l'aube du 20e si\u00e8cle, appr\u00eatez-vous \u00e0 entrer dans un \u00e2ge d'or industriel, ou une d\u00e9pression historique.",
"effectiveDate": "2022-08-11T11:00:00.000Z",
"offerType": "BASE_GAME",
"expiryDate": null,
"status": "ACTIVE",
"isCodeRedemptionOnly": false,
"keyImages": [
{
"type": "OfferImageWide",
"url": "https://cdn1.epicgames.com/spt-assets/a6aeec29591b4b56b4383b4d2d7d0e1e/rise-of-industry-offer-1p22f.jpg"
},
{
"type": "OfferImageTall",
"url": "https://cdn1.epicgames.com/spt-assets/a6aeec29591b4b56b4383b4d2d7d0e1e/download-rise-of-industry-offer-1uujr.jpg"
},
{
"type": "Thumbnail",
"url": "https://cdn1.epicgames.com/spt-assets/a6aeec29591b4b56b4383b4d2d7d0e1e/download-rise-of-industry-offer-1uujr.jpg"
}
],
"seller": {
"id": "o-fnqgc5v2xczx9fgawvcejwj88z2mnx",
"name": "Kasedo Games Ltd"
},
"productSlug": null,
"urlSlug": "f88fedc022fe488caaedaa5c782ff90d",
"url": null,
"items": [
{
"id": "9f5b48a778824e6aa330d2c1a47f41b2",
"namespace": "9f101e25b1a9427a9e6971d2b21c5f82"
}
],
"customAttributes": [
{
"key": "autoGeneratedPrice",
"value": "false"
},
{
"key": "isManuallySetPCReleaseDate",
"value": "true"
}
],
"categories": [
{
"path": "freegames"
},
{
"path": "games/edition/base"
},
{
"path": "games/edition"
},
{
"path": "games"
}
],
"tags": [
{
"id": "26789"
},
{
"id": "19847"
},
{
"id": "1370"
},
{
"id": "1115"
},
{
"id": "9547"
},
{
"id": "10719"
}
],
"catalogNs": {
"mappings": [
{
"pageSlug": "rise-of-industry-0af838",
"pageType": "productHome"
}
]
},
"offerMappings": [
{
"pageSlug": "rise-of-industry-0af838",
"pageType": "productHome"
}
],
"price": {
"totalPrice": {
"discountPrice": 0,
"originalPrice": 2999,
"voucherDiscount": 0,
"discount": 2999,
"currencyCode": "EUR",
"currencyInfo": {
"decimals": 2
},
"fmtPrice": {
"originalPrice": "29,99\u00a0\u20ac",
"discountPrice": "0",
"intermediatePrice": "0"
}
},
"lineOffers": [
{
"appliedRules": [
{
"id": "a19d30dc34f44923993e68b82b75a084",
"endDate": "2023-03-09T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE"
}
}
]
}
]
},
"promotions": {
"promotionalOffers": [
{
"promotionalOffers": [
{
"startDate": "2023-03-02T16:00:00.000Z",
"endDate": "2023-03-09T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 0
}
}
]
}
],
"upcomingPromotionalOffers": [
{
"promotionalOffers": [
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 25
}
},
{
"startDate": "2023-03-09T16:00:00.000Z",
"endDate": "2023-03-16T16:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 25
}
}
]
}
]
}
},
{
"title": "Dishonored - Definitive Edition",
"id": "4d25d74b88d1474a8ab21ffb88ca6d37",
"namespace": "d5241c76f178492ea1540fce45616757",
"description": "Experience the definitive Dishonored collection. This complete compilation includes Dishonored as well as all of its additional content - Dunwall City Trials, The Knife of Dunwall, The Brigmore Witches and Void Walker\u2019s Arsenal.",
"effectiveDate": "2099-01-01T00:00:00.000Z",
"offerType": "OTHERS",
"expiryDate": null,
"status": "ACTIVE",
"isCodeRedemptionOnly": true,
"keyImages": [
{
"type": "VaultClosed",
"url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/15days-day15-wrapped-desktop-carousel-image_1920x1080-ebecfa7c79f02a9de5bca79560bee953"
},
{
"type": "DieselStoreFrontWide",
"url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/15days-day15-Unwrapped-desktop-carousel-image1_1920x1080-1992edb42bb8554ddeb14d430ba3f858"
},
{
"type": "DieselStoreFrontTall",
"url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/DAY15-carousel-mobile-unwrapped-image1_1200x1600-9716d77667d2a82931c55a4e4130989e"
}
],
"seller": {
"id": "o-ufmrk5furrrxgsp5tdngefzt5rxdcn",
"name": "Epic Dev Test Account"
},
"productSlug": "dishonored-definitive-edition",
"urlSlug": "mystery-game15",
"url": null,
"items": [
{
"id": "8341d7c7e4534db7848cc428aa4cbe5a",
"namespace": "d5241c76f178492ea1540fce45616757"
}
],
"customAttributes": [
{
"key": "com.epicgames.app.freegames.vault.close",
"value": "[]"
},
{
"key": "com.epicgames.app.freegames.vault.slug",
"value": "sales-and-specials/holiday-sale"
},
{
"key": "com.epicgames.app.blacklist",
"value": "[]"
},
{
"key": "com.epicgames.app.freegames.vault.open",
"value": "[]"
},
{
"key": "com.epicgames.app.productSlug",
"value": "dishonored-definitive-edition"
}
],
"categories": [
{
"path": "freegames/vaulted"
},
{
"path": "freegames"
},
{
"path": "games"
},
{
"path": "applications"
}
],
"tags": [],
"catalogNs": {
"mappings": []
},
"offerMappings": [],
"price": {
"totalPrice": {
"discountPrice": 0,
"originalPrice": 0,
"voucherDiscount": 0,
"discount": 0,
"currencyCode": "EUR",
"currencyInfo": {
"decimals": 2
},
"fmtPrice": {
"originalPrice": "0",
"discountPrice": "0",
"intermediatePrice": "0"
}
},
"lineOffers": [
{
"appliedRules": []
}
]
},
"promotions": null
}
],
"paging": {
"count": 1000,
"total": 4
}
}
}
},
"extensions": {}
}

View file

@ -0,0 +1,162 @@
"""Tests for the Epic Games Store calendars."""
from unittest.mock import Mock
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.calendar import (
DOMAIN as CALENDAR_DOMAIN,
EVENT_END_DATETIME,
EVENT_START_DATETIME,
SERVICE_GET_EVENTS,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from .common import setup_platform
from tests.common import async_fire_time_changed
async def test_setup_component(hass: HomeAssistant, service_multiple: Mock) -> None:
"""Test setup component."""
await setup_platform(hass, CALENDAR_DOMAIN)
state = hass.states.get("calendar.epic_games_store_discount_games")
assert state.name == "Epic Games Store Discount games"
state = hass.states.get("calendar.epic_games_store_free_games")
assert state.name == "Epic Games Store Free games"
async def test_discount_games(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_multiple: Mock,
) -> None:
"""Test discount games calendar."""
freezer.move_to("2022-10-15T00:00:00.000Z")
await setup_platform(hass, CALENDAR_DOMAIN)
state = hass.states.get("calendar.epic_games_store_discount_games")
assert state.state == STATE_OFF
freezer.move_to("2022-10-30T00:00:00.000Z")
async_fire_time_changed(hass)
state = hass.states.get("calendar.epic_games_store_discount_games")
assert state.state == STATE_ON
cal_attrs = dict(state.attributes)
assert cal_attrs == {
"friendly_name": "Epic Games Store Discount games",
"message": "Shadow of the Tomb Raider: Definitive Edition",
"all_day": False,
"start_time": "2022-10-18 08:00:00",
"end_time": "2022-11-01 08:00:00",
"location": "",
"description": "In Shadow of the Tomb Raider Definitive Edition experience the final chapter of Lara\u2019s origin as she is forged into the Tomb Raider she is destined to be.\n\nhttps://store.epicgames.com/fr/p/shadow-of-the-tomb-raider",
}
async def test_free_games(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_multiple: Mock,
) -> None:
"""Test free games calendar."""
freezer.move_to("2022-10-30T00:00:00.000Z")
await setup_platform(hass, CALENDAR_DOMAIN)
state = hass.states.get("calendar.epic_games_store_free_games")
assert state.state == STATE_ON
cal_attrs = dict(state.attributes)
assert cal_attrs == {
"friendly_name": "Epic Games Store Free games",
"message": "Warhammer 40,000: Mechanicus - Standard Edition",
"all_day": False,
"start_time": "2022-10-27 08:00:00",
"end_time": "2022-11-03 08:00:00",
"location": "",
"description": "Take control of the most technologically advanced army in the Imperium - The Adeptus Mechanicus. Your every decision will weigh heavily on the outcome of the mission, in this turn-based tactical game. Will you be blessed by the Omnissiah?\n\nhttps://store.epicgames.com/fr/p/warhammer-mechanicus-0e4b71",
}
async def test_attribute_not_found(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_attribute_not_found: Mock,
) -> None:
"""Test setup calendars with attribute not found error."""
freezer.move_to("2023-10-12T00:00:00.000Z")
await setup_platform(hass, CALENDAR_DOMAIN)
state = hass.states.get("calendar.epic_games_store_discount_games")
assert state.name == "Epic Games Store Discount games"
state = hass.states.get("calendar.epic_games_store_free_games")
assert state.name == "Epic Games Store Free games"
assert state.state == STATE_ON
async def test_christmas_special(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_christmas_special: Mock,
) -> None:
"""Test setup calendars with Christmas special case."""
freezer.move_to("2023-12-28T00:00:00.000Z")
await setup_platform(hass, CALENDAR_DOMAIN)
state = hass.states.get("calendar.epic_games_store_discount_games")
assert state.name == "Epic Games Store Discount games"
assert state.state == STATE_OFF
state = hass.states.get("calendar.epic_games_store_free_games")
assert state.name == "Epic Games Store Free games"
assert state.state == STATE_ON
async def test_get_events(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_multiple: Mock,
) -> None:
"""Test setup component with calendars."""
freezer.move_to("2022-10-30T00:00:00.000Z")
await setup_platform(hass, CALENDAR_DOMAIN)
# 1 week in range of data
result = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
ATTR_ENTITY_ID: ["calendar.epic_games_store_discount_games"],
EVENT_START_DATETIME: dt_util.parse_datetime("2022-10-20T00:00:00.000Z"),
EVENT_END_DATETIME: dt_util.parse_datetime("2022-10-27T00:00:00.000Z"),
},
blocking=True,
return_response=True,
)
assert len(result["calendar.epic_games_store_discount_games"]["events"]) == 3
# 1 week out of range of data
result = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
ATTR_ENTITY_ID: ["calendar.epic_games_store_discount_games"],
EVENT_START_DATETIME: dt_util.parse_datetime("1970-01-01T00:00:00.000Z"),
EVENT_END_DATETIME: dt_util.parse_datetime("1970-01-08T00:00:00.000Z"),
},
blocking=True,
return_response=True,
)
assert len(result["calendar.epic_games_store_discount_games"]["events"]) == 0

View file

@ -0,0 +1,142 @@
"""Test the Epic Games Store config flow."""
from http.client import HTTPException
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.epic_games_store.config_flow import get_default_language
from homeassistant.components.epic_games_store.const import DOMAIN
from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import (
DATA_ERROR_ATTRIBUTE_NOT_FOUND,
DATA_ERROR_WRONG_COUNTRY,
DATA_FREE_GAMES,
MOCK_COUNTRY,
MOCK_LANGUAGE,
)
async def test_default_language(hass: HomeAssistant) -> None:
"""Test we get the form."""
hass.config.language = "fr"
hass.config.country = "FR"
assert get_default_language(hass) == "fr"
hass.config.language = "es"
hass.config.country = "ES"
assert get_default_language(hass) == "es-ES"
hass.config.language = "en"
hass.config.country = "AZ"
assert get_default_language(hass) is None
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] is None
with patch(
"homeassistant.components.epic_games_store.config_flow.EpicGamesStoreAPI.get_free_games",
return_value=DATA_FREE_GAMES,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LANGUAGE: MOCK_LANGUAGE,
CONF_COUNTRY: MOCK_COUNTRY,
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["result"].unique_id == f"freegames-{MOCK_LANGUAGE}-{MOCK_COUNTRY}"
assert (
result2["title"]
== f"Epic Games Store - Free Games ({MOCK_LANGUAGE}-{MOCK_COUNTRY})"
)
assert result2["data"] == {
CONF_LANGUAGE: MOCK_LANGUAGE,
CONF_COUNTRY: MOCK_COUNTRY,
}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.epic_games_store.config_flow.EpicGamesStoreAPI.get_free_games",
side_effect=HTTPException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LANGUAGE: MOCK_LANGUAGE,
CONF_COUNTRY: MOCK_COUNTRY,
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
async def test_form_cannot_connect_wrong_param(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.epic_games_store.config_flow.EpicGamesStoreAPI.get_free_games",
return_value=DATA_ERROR_WRONG_COUNTRY,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LANGUAGE: MOCK_LANGUAGE,
CONF_COUNTRY: MOCK_COUNTRY,
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
async def test_form_service_error(hass: HomeAssistant) -> None:
"""Test we handle service error gracefully."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.epic_games_store.config_flow.EpicGamesStoreAPI.get_free_games",
return_value=DATA_ERROR_ATTRIBUTE_NOT_FOUND,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LANGUAGE: MOCK_LANGUAGE,
CONF_COUNTRY: MOCK_COUNTRY,
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["result"].unique_id == f"freegames-{MOCK_LANGUAGE}-{MOCK_COUNTRY}"
assert (
result2["title"]
== f"Epic Games Store - Free Games ({MOCK_LANGUAGE}-{MOCK_COUNTRY})"
)
assert result2["data"] == {
CONF_LANGUAGE: MOCK_LANGUAGE,
CONF_COUNTRY: MOCK_COUNTRY,
}

View file

@ -0,0 +1,74 @@
"""Tests for the Epic Games Store helpers."""
from typing import Any
import pytest
from homeassistant.components.epic_games_store.helper import (
format_game_data,
get_game_url,
is_free_game,
)
from .const import DATA_ERROR_ATTRIBUTE_NOT_FOUND, DATA_FREE_GAMES_ONE
FREE_GAMES_API = DATA_FREE_GAMES_ONE["data"]["Catalog"]["searchStore"]["elements"]
FREE_GAME = FREE_GAMES_API[2]
NOT_FREE_GAME = FREE_GAMES_API[0]
def test_format_game_data() -> None:
"""Test game data format."""
game_data = format_game_data(FREE_GAME, "fr")
assert game_data
assert game_data["title"]
assert game_data["description"]
assert game_data["released_at"]
assert game_data["original_price"]
assert game_data["publisher"]
assert game_data["url"]
assert game_data["img_portrait"]
assert game_data["img_landscape"]
assert game_data["discount_type"] == "free"
assert game_data["discount_start_at"]
assert game_data["discount_end_at"]
@pytest.mark.parametrize(
("raw_game_data", "expected_result"),
[
(
DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"]["searchStore"][
"elements"
][1],
"/p/destiny-2--bungie-30th-anniversary-pack",
),
(
DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"]["searchStore"][
"elements"
][4],
"/bundles/qube-ultimate-bundle",
),
(
DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"]["searchStore"][
"elements"
][5],
"/p/mystery-game-7",
),
],
)
def test_get_game_url(raw_game_data: dict[str, Any], expected_result: bool) -> None:
"""Test to get the game URL."""
assert get_game_url(raw_game_data, "fr").endswith(expected_result)
@pytest.mark.parametrize(
("raw_game_data", "expected_result"),
[
(FREE_GAME, True),
(NOT_FREE_GAME, False),
],
)
def test_is_free_game(raw_game_data: dict[str, Any], expected_result: bool) -> None:
"""Test if this game is free."""
assert is_free_game(raw_game_data) == expected_result