diff --git a/src/splatnet3_scraper/auth/tokens/manager.py b/src/splatnet3_scraper/auth/tokens/manager.py index 53bc1a7..f3e9ee1 100644 --- a/src/splatnet3_scraper/auth/tokens/manager.py +++ b/src/splatnet3_scraper/auth/tokens/manager.py @@ -269,8 +269,7 @@ def ready_for_endpoint(self, endpoint: str = "web") -> bool: return now < self._web_service_token_expires_at def ensure_tokens_valid(self) -> None: - now = time.time() - if now < self.next_available_at: + if time.time() < self.next_available_at: raise AccountCooldownException( "Account is cooling down for another" f" {self.cooldown_remaining():.1f} seconds." @@ -294,9 +293,6 @@ def ensure_tokens_valid(self) -> None: if bullet_token.is_expired: self.generate_bullet_token() - if now >= self._id_token_expires_at: - self.regenerate_tokens() - def record_response(self, status_code: int) -> None: self.last_status_code = status_code if status_code in (429, 503): diff --git a/src/splatnet3_scraper/query/config/config.py b/src/splatnet3_scraper/query/config/config.py index b1ade80..b32f19f 100644 --- a/src/splatnet3_scraper/query/config/config.py +++ b/src/splatnet3_scraper/query/config/config.py @@ -13,6 +13,10 @@ ) T = TypeVar("T") +TOKEN_TIMESTAMP_OPTIONS = { + TOKENS.GTOKEN: "gtoken_timestamp", + TOKENS.BULLET_TOKEN: "bullet_token_timestamp", +} class Config: @@ -104,10 +108,7 @@ def regenerate_tokens(self) -> None: TOKENS.GTOKEN, TOKENS.BULLET_TOKEN, ]: - self.handler.set_value( - token, - self.token_manager.get_token(token).value, - ) + self._sync_token_to_handler(token) if self._output_file_path is not None: try: self.save_to_file() @@ -251,6 +252,25 @@ def _configure_token_manager(self) -> None: exc, ) + @staticmethod + def _timestamp_option_for_token(token_name: str) -> str | None: + return TOKEN_TIMESTAMP_OPTIONS.get(token_name) + + def _sync_token_to_handler(self, token_name: str) -> None: + token = self.token_manager.get_token(token_name) + self.handler.set_value(token_name, token.value) + if timestamp_option := self._timestamp_option_for_token(token_name): + self.handler.set_value(timestamp_option, str(token.timestamp)) + + def _get_token_timestamp(self, token_name: str) -> float | None: + timestamp_option = self._timestamp_option_for_token(token_name) + if timestamp_option is None: + return None + try: + return cast(float | None, self.handler.get_value(timestamp_option)) + except ValueError: + return None + def get_value( self, option: str, default: T | None = None ) -> str | T | None: @@ -282,10 +302,29 @@ def set_value(self, option: str, value: str | None) -> None: TOKENS.GTOKEN, TOKENS.BULLET_TOKEN, ]: + timestamp = self._get_token_timestamp(option) if (token := self.handler.tokens[option]) is not None: self.token_manager.add_token( token, option, + timestamp=timestamp, + ) + if ( + timestamp is None + and self._timestamp_option_for_token(option) is not None + ): + self._sync_token_to_handler(option) + elif option in TOKEN_TIMESTAMP_OPTIONS.values(): + token_name = next( + name + for name, timestamp_option in TOKEN_TIMESTAMP_OPTIONS.items() + if timestamp_option == option + ) + if (token := self.handler.tokens[token_name]) is not None: + self.token_manager.add_token( + token, + token_name, + timestamp=self._get_token_timestamp(token_name), ) elif option == "app_version_override": self._apply_app_version_override() @@ -354,9 +393,17 @@ def from_tokens( prefix = prefix or Config.DEFAULT_PREFIX handler = ConfigOptionHandler(prefix=prefix) - handler.set_value(TOKENS.SESSION_TOKEN, session_token) - handler.set_value(TOKENS.GTOKEN, gtoken) - handler.set_value(TOKENS.BULLET_TOKEN, bullet_token) + handler.set_value( + TOKENS.SESSION_TOKEN, + token_manager.get_token(TOKENS.SESSION_TOKEN).value, + ) + for token_name in (TOKENS.GTOKEN, TOKENS.BULLET_TOKEN): + token = token_manager.get_token(token_name) + handler.set_value(token_name, token.value) + handler.set_value( + cast(str, TOKEN_TIMESTAMP_OPTIONS[token_name]), + str(token.timestamp), + ) handler.set_value("app_version_override", app_version) return Config( @@ -392,10 +439,22 @@ def from_config_handler( gtoken = handler.get_value(TOKENS.GTOKEN) except ValueError: gtoken = None + try: + gtoken_timestamp = cast( + float | None, handler.get_value("gtoken_timestamp") + ) + except ValueError: + gtoken_timestamp = None try: bullet_token = handler.get_value(TOKENS.BULLET_TOKEN) except ValueError: bullet_token = None + try: + bullet_token_timestamp = cast( + float | None, handler.get_value("bullet_token_timestamp") + ) + except ValueError: + bullet_token_timestamp = None try: app_version = handler.get_value("app_version_override") except ValueError: @@ -408,9 +467,17 @@ def from_config_handler( app_version=cast(str | None, app_version), ) if gtoken is not None: - token_manager.add_token(gtoken, TOKENS.GTOKEN) + token_manager.add_token( + gtoken, + TOKENS.GTOKEN, + timestamp=gtoken_timestamp, + ) if bullet_token is not None: - token_manager.add_token(bullet_token, TOKENS.BULLET_TOKEN) + token_manager.add_token( + bullet_token, + TOKENS.BULLET_TOKEN, + timestamp=bullet_token_timestamp, + ) if gtoken is not None and bullet_token is not None: token_manager.mark_tokens_fresh() diff --git a/src/splatnet3_scraper/query/config/config_option_handler.py b/src/splatnet3_scraper/query/config/config_option_handler.py index 696af89..9f6de25 100644 --- a/src/splatnet3_scraper/query/config/config_option_handler.py +++ b/src/splatnet3_scraper/query/config/config_option_handler.py @@ -65,6 +65,20 @@ class ConfigOptionHandler: deprecated_names=["bullettoken"], env_var="BULLET_TOKEN", ), + ConfigOption[float]( + name="gtoken_timestamp", + default=None, + section="tokens", + callback=float, + save_callback=lambda value: str(value), + ), + ConfigOption[float]( + name="bullet_token_timestamp", + default=None, + section="tokens", + callback=float, + save_callback=lambda value: str(value), + ), ConfigOption[str]( name="user_agent", default=DEFAULT_USER_AGENT, diff --git a/tests/auth/tokens/test_manager.py b/tests/auth/tokens/test_manager.py index 76b0164..8936a8e 100644 --- a/tests/auth/tokens/test_manager.py +++ b/tests/auth/tokens/test_manager.py @@ -423,4 +423,31 @@ def get_token(name: str, *, full_token: bool = True): mock_token_manager.ensure_tokens_valid() mock_token_manager.generate_gtoken.assert_called_once() - mock_token_manager.regenerate_tokens.assert_called_once() + mock_token_manager.regenerate_tokens.assert_not_called() + + def test_ensure_tokens_valid_does_not_force_full_refresh_for_stale_app_timer( + self, mock_token_manager: TokenManager, monkeypatch: pytest.MonkeyPatch + ) -> None: + tokens = { + TOKENS.GTOKEN: MagicMock(is_expired=False, value="gtoken"), + TOKENS.BULLET_TOKEN: MagicMock(is_expired=False, value="bullet"), + } + + def get_token(name: str, *, full_token: bool = True): + return tokens[name] + + mock_token_manager.keychain.get.side_effect = get_token + mock_token_manager.generate_gtoken = MagicMock() + mock_token_manager.generate_bullet_token = MagicMock() + mock_token_manager.regenerate_tokens = MagicMock() + mock_token_manager._id_token_expires_at = 400.0 + + monkeypatch.setattr( + f"{base_token_manager_path}.time.time", lambda: 500.0 + ) + + mock_token_manager.ensure_tokens_valid() + + mock_token_manager.generate_gtoken.assert_not_called() + mock_token_manager.generate_bullet_token.assert_not_called() + mock_token_manager.regenerate_tokens.assert_not_called() diff --git a/tests/query/configuration/test_config.py b/tests/query/configuration/test_config.py index 17ba34d..b5d9180 100644 --- a/tests/query/configuration/test_config.py +++ b/tests/query/configuration/test_config.py @@ -43,14 +43,24 @@ def test_token_manager_property(self) -> None: def test_regenerate_tokens(self) -> None: mock_token_manager = MagicMock() mock_handler = MagicMock() + mock_token_manager.get_token.side_effect = lambda token_name: { + TOKENS.SESSION_TOKEN: MagicMock(value="session", timestamp=1.0), + TOKENS.GTOKEN: MagicMock(value="gtoken", timestamp=2.0), + TOKENS.BULLET_TOKEN: MagicMock(value="bullet", timestamp=3.0), + }[token_name] config = Config(mock_handler, token_manager=mock_token_manager) config.regenerate_tokens() mock_token_manager.regenerate_tokens.assert_called_once_with() - assert mock_handler.set_value.call_count == 3 + assert mock_handler.set_value.call_count == 5 def test_regenerate_tokens_saves_file_backed_config(self) -> None: mock_token_manager = MagicMock() mock_handler = MagicMock() + mock_token_manager.get_token.side_effect = lambda token_name: { + TOKENS.SESSION_TOKEN: MagicMock(value="session", timestamp=1.0), + TOKENS.GTOKEN: MagicMock(value="gtoken", timestamp=2.0), + TOKENS.BULLET_TOKEN: MagicMock(value="bullet", timestamp=3.0), + }[token_name] config = Config( mock_handler, token_manager=mock_token_manager, @@ -61,12 +71,17 @@ def test_regenerate_tokens_saves_file_backed_config(self) -> None: config.regenerate_tokens() mock_token_manager.regenerate_tokens.assert_called_once_with() - assert mock_handler.set_value.call_count == 3 + assert mock_handler.set_value.call_count == 5 config.save_to_file.assert_called_once_with() def test_regenerate_tokens_ignores_save_file_errors(self) -> None: mock_token_manager = MagicMock() mock_handler = MagicMock() + mock_token_manager.get_token.side_effect = lambda token_name: { + TOKENS.SESSION_TOKEN: MagicMock(value="session", timestamp=1.0), + TOKENS.GTOKEN: MagicMock(value="gtoken", timestamp=2.0), + TOKENS.BULLET_TOKEN: MagicMock(value="bullet", timestamp=3.0), + }[token_name] config = Config( mock_handler, token_manager=mock_token_manager, @@ -77,7 +92,7 @@ def test_regenerate_tokens_ignores_save_file_errors(self) -> None: config.regenerate_tokens() mock_token_manager.regenerate_tokens.assert_called_once_with() - assert mock_handler.set_value.call_count == 3 + assert mock_handler.set_value.call_count == 5 config.save_to_file.assert_called_once_with() def test_regenerate_tokens_does_not_save_without_file_path(self) -> None: @@ -154,13 +169,17 @@ def test_get_value(self, value: str | None, default: str | None) -> None: def test_set_value(self, option: str) -> None: mock_handler = MagicMock() mock_token_manager = MagicMock() - config = Config(mock_handler, token_manager=mock_token_manager) + mock_handler.get_value.side_effect = ValueError("missing") + mock_token_manager.get_token.return_value.timestamp = 123.0 + with patch.object(Config, "_configure_token_manager"): + config = Config(mock_handler, token_manager=mock_token_manager) config.set_value(option, "test") mock_handler.set_value.assert_called_once_with(option, "test") if option == TOKENS.SESSION_TOKEN: mock_token_manager.add_token.assert_called_once_with( mock_handler.tokens[option], option, + timestamp=None, ) def test_set_value_app_version_override(self) -> None: @@ -174,6 +193,32 @@ def test_set_value_app_version_override(self) -> None: ) config._apply_app_version_override.assert_called_once_with() + def test_set_value_token_uses_saved_timestamp_when_present(self) -> None: + mock_handler = MagicMock() + mock_token_manager = MagicMock() + + def get_value_side_effect(key: str): + if key == "gtoken_timestamp": + return 123.0 + raise ValueError("missing") + + mock_handler.get_value.side_effect = get_value_side_effect + mock_handler.tokens = { + TOKENS.SESSION_TOKEN: None, + TOKENS.GTOKEN: "test_gtoken", + TOKENS.BULLET_TOKEN: None, + } + + with patch.object(Config, "_configure_token_manager"): + config = Config(mock_handler, token_manager=mock_token_manager) + config.set_value(TOKENS.GTOKEN, "test_gtoken") + + mock_token_manager.add_token.assert_called_once_with( + "test_gtoken", + TOKENS.GTOKEN, + timestamp=123.0, + ) + def test_config_configures_nxapi_with_client_version(self) -> None: handler = ConfigOptionHandler() handler.set_value(TOKENS.SESSION_TOKEN, "session") @@ -284,6 +329,14 @@ def test_from_tokens(self, prefix: str) -> None: gtoken = MagicMock() bullet_token = MagicMock() mock_token_manager = MagicMock() + mock_token_manager.get_token.side_effect = lambda token_name: { + TOKENS.SESSION_TOKEN: MagicMock(value=session_token), + TOKENS.GTOKEN: MagicMock(value=gtoken, timestamp=123.0), + TOKENS.BULLET_TOKEN: MagicMock( + value=bullet_token, + timestamp=456.0, + ), + }[token_name] with ( patch(base_config_path + ".ConfigOptionHandler") as mock_handler, @@ -306,10 +359,16 @@ def test_from_tokens(self, prefix: str) -> None: ) expected_prefix = prefix or "SN3S" mock_handler.assert_called_once_with(prefix=expected_prefix) - assert mock_handler.return_value.set_value.call_count == 4 + assert mock_handler.return_value.set_value.call_count == 6 mock_handler.return_value.set_value.assert_any_call( "app_version_override", None ) + mock_handler.return_value.set_value.assert_any_call( + "gtoken_timestamp", "123.0" + ) + mock_handler.return_value.set_value.assert_any_call( + "bullet_token_timestamp", "456.0" + ) mock_config.assert_called_once_with( mock_handler.return_value, token_manager=mock_token_manager, @@ -334,13 +393,17 @@ def test_from_config_handler(self, save_to_file: bool) -> None: mock_session = "session_token_value" mock_gtoken = "gtoken_value" mock_bullet = "bullet_token_value" + mock_gtoken_timestamp = 123.0 + mock_bullet_timestamp = 456.0 mock_app_version = "3.2.1" def get_value_side_effect(key: str): return { "session_token": mock_session, "gtoken": mock_gtoken, + "gtoken_timestamp": mock_gtoken_timestamp, "bullet_token": mock_bullet, + "bullet_token_timestamp": mock_bullet_timestamp, "app_version_override": mock_app_version, }[key] @@ -361,9 +424,15 @@ def get_value_side_effect(key: str): mock_session, app_version=mock_app_version, ) - mock_token_manager.add_token.assert_any_call(mock_gtoken, "gtoken") mock_token_manager.add_token.assert_any_call( - mock_bullet, "bullet_token" + mock_gtoken, + "gtoken", + timestamp=mock_gtoken_timestamp, + ) + mock_token_manager.add_token.assert_any_call( + mock_bullet, + "bullet_token", + timestamp=mock_bullet_timestamp, ) mock_token_manager.mark_tokens_fresh.assert_called_once_with() mock_config.assert_called_once_with( @@ -371,7 +440,7 @@ def get_value_side_effect(key: str): token_manager=mock_token_manager, output_file_path=expected_file_path, ) - assert mock_handler.get_value.call_count == 4 + assert mock_handler.get_value.call_count == 6 @pytest.mark.parametrize( ("gtoken", "bullet_token"), @@ -398,7 +467,9 @@ def get_value_side_effect(key: str): values = { "session_token": "session_token_value", "gtoken": gtoken, + "gtoken_timestamp": None, "bullet_token": bullet_token, + "bullet_token_timestamp": None, "app_version_override": None, } if values[key] is None and key != "app_version_override": @@ -421,7 +492,11 @@ def test_from_config_handler_without_app_version_uses_none(self) -> None: mock_token_manager = MagicMock() def get_value_side_effect(key: str): - if key == "app_version_override": + if key in { + "app_version_override", + "gtoken_timestamp", + "bullet_token_timestamp", + }: raise ValueError("missing") return { "session_token": "session_token_value",