diff --git a/src/features/AutoCheckin/utils/autoCheckin.ts b/src/features/AutoCheckin/utils/autoCheckin.ts index 745cd1da75..15be09bf3c 100644 --- a/src/features/AutoCheckin/utils/autoCheckin.ts +++ b/src/features/AutoCheckin/utils/autoCheckin.ts @@ -26,6 +26,8 @@ export function translateAutoCheckinMessageKey( "autoCheckin:providerFallback.endpointNotSupported", messageParams, ) + case "autoCheckin:providerFallback.sub2apiDisabled": + return t("autoCheckin:providerFallback.sub2apiDisabled", messageParams) case "autoCheckin:providerFallback.nativePageIdentityMismatch": return t( "autoCheckin:providerFallback.nativePageIdentityMismatch", diff --git a/src/features/BasicSettings/components/tabs/CheckinRedeem/AutoCheckinSettings.tsx b/src/features/BasicSettings/components/tabs/CheckinRedeem/AutoCheckinSettings.tsx index 90814fc93f..de7fda2177 100644 --- a/src/features/BasicSettings/components/tabs/CheckinRedeem/AutoCheckinSettings.tsx +++ b/src/features/BasicSettings/components/tabs/CheckinRedeem/AutoCheckinSettings.tsx @@ -30,6 +30,8 @@ import { createLogger } from "~/utils/core/logger" import { getPreferenceWriteFailureMessage } from "~/utils/core/toastHelpers" import { pushWithinOptionsPage } from "~/utils/navigation" +import { AUTO_CHECKIN_SUB2API_TARGET_ID } from "./targetIds" + /** * Unified logger scoped to the Basic Settings auto check-in section. */ @@ -231,6 +233,22 @@ export default function AutoCheckinSettings() { } /> + {/* Sub2API check-in opt-in */} + + savePreferences({ sub2apiEnabled: checked }) + } + disabled={isSaving} + /> + } + /> + {/* Time Window Start */} ): unknown { + if (Array.isArray(value)) { + for (const item of value) { + const found = findValue(item, keys) + if (found !== undefined) return found + } + return undefined + } + + if (!value || typeof value !== "object") { + return undefined + } + + const record = value as Record + + for (const [key, item] of Object.entries(record)) { + if (keys.has(key.toLowerCase())) { + return item + } + } + + for (const item of Object.values(record)) { + const found = findValue(item, keys) + if (found !== undefined) return found + } + + return undefined +} + +/** + * Extract a human-readable backend message from an envelope or its `data`. + */ +export function extractSub2ApiCheckinMessage(body: unknown): string { + if (!body || typeof body !== "object") return "" + + const record = body as Record + const sources: unknown[] = [record, record.data] + + for (const source of sources) { + if (!source || typeof source !== "object") continue + const sourceRecord = source as Record + + for (const key of MESSAGE_KEYS) { + const value = sourceRecord[key] + if (typeof value === "string" && value.trim()) { + return value.trim() + } + } + } + + return "" +} + +/** + * Detect an "already checked in today" outcome from backend copy. + */ +export function isSub2ApiAlreadyCheckedMessage(message: string): boolean { + const normalized = message.toLowerCase() + return ALREADY_CHECKED_SNIPPETS.some((snippet) => + normalized.includes(snippet.toLowerCase()), + ) +} + +/** + * Resolve today's check-in flag from a status/check-in payload. + * + * Order matters: an explicit boolean beats a date comparison, and free-text + * matching is only a last resort for deployments that report neither. + */ +function resolveIsCheckedInToday(body: unknown, message: string): boolean { + const flag = findValue(body, CHECKED_FLAG_KEYS) + if (typeof flag === "boolean") return flag + if (typeof flag === "number") return flag !== 0 + + const lastCheckin = findValue(body, CHECKED_DATE_KEYS) + if (typeof lastCheckin === "string" && lastCheckin.length >= 10) { + const today = new Date().toISOString().slice(0, 10) + if (lastCheckin.slice(0, 10) === today) return true + } + + return isSub2ApiAlreadyCheckedMessage(message) +} + +/** + * Resolve the awarded amount, if the deployment reports one. + */ +function resolveReward(body: unknown): string { + const reward = findValue(body, REWARD_KEYS) + + if ( + reward === undefined || + reward === null || + typeof reward === "boolean" || + typeof reward === "object" + ) { + return "" + } + + return String(reward) +} + +/** + * Normalize a raw Sub2API check-in status or check-in response body. + */ +export function parseSub2ApiCheckinPayload( + body: unknown, +): Sub2ApiCheckinPayload { + const message = extractSub2ApiCheckinMessage(body) + + return { + isCheckedInToday: resolveIsCheckedInToday(body, message), + reward: resolveReward(body), + message, + } +} + +/** + * Whether an error means the deployment does not expose the attempted route. + */ +export function isSub2ApiMissingRouteError(error: unknown): boolean { + return ( + error instanceof ApiError && + typeof error.statusCode === "number" && + MISSING_ROUTE_STATUS_CODES.has(error.statusCode) + ) +} + +/** + * Whether an error represents a repeated check-in rather than a failure. + */ +export function isSub2ApiAlreadyCheckedError(error: unknown): boolean { + if (error instanceof ApiError) { + if (error.statusCode === ALREADY_CHECKED_STATUS_CODE) return true + return isSub2ApiAlreadyCheckedMessage(error.message) + } + + if (error instanceof Error) { + return isSub2ApiAlreadyCheckedMessage(error.message) + } + + return false +} diff --git a/src/services/apiService/sub2api/index.ts b/src/services/apiService/sub2api/index.ts index 2babb67919..55e50da1e1 100644 --- a/src/services/apiService/sub2api/index.ts +++ b/src/services/apiService/sub2api/index.ts @@ -26,9 +26,11 @@ import type { UserInfo, } from "~/services/apiAdapters/contracts/accountBootstrap" import { extractDefaultExchangeRate as extractNewApiFamilyDefaultExchangeRate } from "~/services/apiService/newApiFamily/default/accountBootstrap" +import { resolveCheckInSiteStatus } from "~/services/apiService/newApiFamily/default/accountData" import { API_ERROR_CODES, ApiError } from "~/services/apiTransport/errors" import { fetchApi } from "~/services/apiTransport/request" import type { ApiServiceRequest } from "~/services/apiTransport/type" +import { isSub2ApiCheckinEnabled } from "~/services/checkin/sub2apiCheckinPreference" import { INVITE_LINK_FAILURE_REASONS, InviteLinkError, @@ -46,6 +48,14 @@ import { createLogger } from "~/utils/core/logger" import { t } from "~/utils/i18n/core" import { getSub2ApiAuthSession, type Sub2ApiAuthSession } from "./authSession" +import { + isSub2ApiAlreadyCheckedError, + isSub2ApiMissingRouteError, + parseSub2ApiCheckinPayload, + SUB2API_CHECKIN_ROUTES, + type Sub2ApiCheckinPayload, + type Sub2ApiCheckinRoute, +} from "./checkin" import { buildSub2ApiUserGroups, extractSub2ApiKeyItems, @@ -419,13 +429,25 @@ const resyncSub2ApiRequestAuth = async < source: resynced.source, }) - const resyncedRequest = applySub2ApiAuthUpdate(latestRequest, { + // Carry the re-synced refresh token through as well: the stored one is + // normally dead by this point (the dashboard rotated it), so restoring only + // the access token would leave the account unable to renew on its own. + const resyncedAuthUpdate: PersistableSub2ApiAuthUpdate = { accessToken: resynced.accessToken, - }) + ...(resynced.refreshToken ? { refreshToken: resynced.refreshToken } : {}), + ...(typeof resynced.tokenExpiresAt === "number" + ? { tokenExpiresAt: resynced.tokenExpiresAt } + : {}), + } + + const resyncedRequest = applySub2ApiAuthUpdate( + latestRequest, + resyncedAuthUpdate, + ) await persistSub2ApiAuthUpdate( resyncedRequest, - { accessToken: resynced.accessToken }, + resyncedAuthUpdate, latestAuthSession, ) @@ -877,12 +899,31 @@ const createAccountData = ( checkIn, }) -const createDisabledCheckInConfig = ( +/** + * Resolve the check-in config reported back for a Sub2API account. + * + * `enableDetection` itself is owned by the shared support probe in + * `accountStorage` (`fetchCheckInSupport`), so it is passed through untouched + * once the global Sub2API opt-in is on. While the opt-in is off, detection stays + * force-disabled and no check-in request is made. + */ +const resolveSub2ApiCheckInConfig = async ( + request: ApiServiceRequest, checkIn: CheckInConfig, -): CheckInConfig => ({ - ...checkIn, - enableDetection: false, -}) +): Promise => { + if (!(await isSub2ApiCheckinEnabled())) { + return { ...checkIn, enableDetection: false } + } + + const canCheckIn = checkIn.enableDetection + ? await fetchCheckInStatus(request) + : undefined + + return { + ...checkIn, + siteStatus: resolveCheckInSiteStatus(checkIn, canCheckIn), + } +} const createLoginRequiredHealthStatus = () => ({ status: SiteHealthStatus.Warning, @@ -1162,14 +1203,17 @@ export async function getOrCreateAccessToken( /** * Sub2API does not expose the One-API-style public `/api/status` endpoint. - * Return a synthetic status payload so shared callers can skip that request and - * still treat built-in check-in as unsupported. + * Return a synthetic status payload so shared callers can skip that request. + * + * `checkin_enabled` mirrors the user's global Sub2API check-in opt-in rather + * than a deployment capability: the actual route probe lives in + * `fetchSupportCheckIn`, which only runs once the opt-in is on. */ export async function fetchSiteStatus( _request: ApiServiceRequest, ): Promise { return { - checkin_enabled: false, + checkin_enabled: await isSub2ApiCheckinEnabled(), } } @@ -1179,22 +1223,178 @@ export async function fetchSiteStatus( */ export const extractDefaultExchangeRate = extractNewApiFamilyDefaultExchangeRate +type Sub2ApiCheckinProbe = { + route: Sub2ApiCheckinRoute + payload: Sub2ApiCheckinPayload +} + +/** + * Execute a Sub2API endpoint with full JWT handling and return the raw envelope. + * + * Check-in responses are interpreted by heuristics in `./checkin` rather than + * `parseSub2ApiEnvelope`, because a repeated check-in is reported as a non-zero + * envelope code (or HTTP 409) that must not surface as a hard failure. + */ +const fetchSub2ApiRawBody = async ( + request: ApiServiceRequest, + endpoint: string, + options: RequestInit, +): Promise => + executeAuthenticatedSub2ApiRequest(request, endpoint, (authRequest) => + fetchApi(authRequest, { endpoint, options }, true), + ) + /** - * Sub2API does not support the extension's built-in check-in flow. + * Probe the candidate check-in routes and return the first one this deployment + * actually serves, or null when none of them exist. + */ +const probeSub2ApiCheckinStatus = async ( + request: ApiServiceRequest, +): Promise => { + for (const route of SUB2API_CHECKIN_ROUTES) { + try { + const body = await fetchSub2ApiRawBody(request, route.statusEndpoint, { + method: "GET", + cache: "no-store", + }) + + return { route, payload: parseSub2ApiCheckinPayload(body) } + } catch (error) { + if (isSub2ApiMissingRouteError(error)) { + continue + } + + // An "already checked in" answer still proves the route exists. + if (isSub2ApiAlreadyCheckedError(error)) { + return { + route, + payload: { + isCheckedInToday: true, + reward: "", + message: getSafeErrorMessage(error), + }, + } + } + + throw error + } + } + + return null +} + +const createCheckinUnsupportedError = () => + new ApiError( + t("messages:sub2api.checkinUnsupported"), + 404, + SUB2API_CHECKIN_ROUTES[0].statusEndpoint, + API_ERROR_CODES.HTTP_OTHER, + ) + +/** + * Detect whether this Sub2API deployment serves a daily check-in route. + * + * Returns `false` while the global opt-in is off so no probe request is sent, + * and `undefined` when the probe itself failed for an unrelated reason (network, + * auth) so callers do not permanently mark the account as unsupported. */ export async function fetchSupportCheckIn( - _request: ApiServiceRequest, + request: ApiServiceRequest, ): Promise { - return false + if (!(await isSub2ApiCheckinEnabled())) { + return false + } + + try { + return (await probeSub2ApiCheckinStatus(request)) !== null + } catch (error) { + logger.warn("Failed to probe Sub2API check-in support", { + accountId: request.accountId, + error: getSafeErrorMessage(error), + }) + return undefined + } } /** - * Sub2API check-in is unsupported; always return undefined. + * Resolve whether the account can still check in today. + * + * Returns `canCheckIn` (not `isCheckedInToday`) to match the shared New + * API-family contract consumed by `resolveCheckInSiteStatus`, and `undefined` + * when the status cannot be read. */ export async function fetchCheckInStatus( - _request: ApiServiceRequest, + request: ApiServiceRequest, ): Promise { - return undefined + if (!(await isSub2ApiCheckinEnabled())) { + return undefined + } + + try { + const probe = await probeSub2ApiCheckinStatus(request) + return probe ? !probe.payload.isCheckedInToday : undefined + } catch (error) { + logger.warn("Failed to read Sub2API check-in status", { + accountId: request.accountId, + error: getSafeErrorMessage(error), + }) + return undefined + } +} + +interface Sub2ApiCheckinOutcome { + alreadyChecked: boolean + reward: string + message: string +} + +/** + * Perform a Sub2API daily check-in. + * + * Throws an `ApiError` with status 404 when the deployment serves no check-in + * route, so callers can map it to the shared "endpoint not supported" copy. + */ +export async function performSub2ApiCheckin( + request: ApiServiceRequest, +): Promise { + const probe = await probeSub2ApiCheckinStatus(request) + + if (!probe) { + throw createCheckinUnsupportedError() + } + + if (probe.payload.isCheckedInToday) { + return { + alreadyChecked: true, + reward: probe.payload.reward, + message: probe.payload.message, + } + } + + try { + const body = await fetchSub2ApiRawBody( + request, + probe.route.checkinEndpoint, + { method: "POST", body: JSON.stringify({}) }, + ) + const payload = parseSub2ApiCheckinPayload(body) + + return { + alreadyChecked: payload.isCheckedInToday, + reward: payload.reward, + message: payload.message, + } + } catch (error) { + if (isSub2ApiAlreadyCheckedError(error)) { + return { + alreadyChecked: true, + reward: "", + message: getSafeErrorMessage(error), + } + } + + throw error + } } const ZERO_TODAY_USAGE_DATA: TodayUsageData = { @@ -1252,13 +1452,13 @@ export async function fetchTodayIncome( export async function fetchAccountData( request: ApiServiceAccountRequest, ): Promise { - const checkIn: CheckInConfig = { - ...(request.checkIn ?? { enableDetection: false }), - enableDetection: false, - } - - const { currentUser, todayUsage } = - await fetchCurrentUserAndTodayUsage(request) + const [checkIn, { currentUser, todayUsage }] = await Promise.all([ + resolveSub2ApiCheckInConfig( + request, + request.checkIn ?? { enableDetection: false }, + ), + fetchCurrentUserAndTodayUsage(request), + ]) return createAccountData(currentUser, checkIn, todayUsage) } @@ -1269,7 +1469,8 @@ export async function fetchAccountData( export async function refreshAccountData( request: ApiServiceAccountRequest, ): Promise { - const checkIn = createDisabledCheckInConfig( + const checkIn = await resolveSub2ApiCheckInConfig( + request, request.checkIn ?? { enableDetection: false }, ) let hydratedRequest: HydratedSub2ApiAuth | null = diff --git a/src/services/apiService/sub2api/tokenResync.ts b/src/services/apiService/sub2api/tokenResync.ts index 208b1b38ef..fbd943d9b7 100644 --- a/src/services/apiService/sub2api/tokenResync.ts +++ b/src/services/apiService/sub2api/tokenResync.ts @@ -9,6 +9,8 @@ import type { TempWindowRequestSource } from "~/types/tempWindowFetch" type Sub2ApiResyncedToken = { accessToken: string + refreshToken?: string + tokenExpiresAt?: number source: | typeof ACCOUNT_BROWSER_SESSION_SOURCES.EXISTING_TAB | typeof ACCOUNT_BROWSER_SESSION_SOURCES.TEMP_WINDOW @@ -41,6 +43,11 @@ const mapResyncSource = ( * Strategy: * 1) Prefer an already-open same-origin tab through the browser-session reader. * 2) Fall back to the temp-window auto-detect context. + * + * The refresh token is carried back alongside the access token: Sub2API rotates + * refresh tokens single-use, so a rotation performed by the site's own dashboard + * leaves the stored one dead. Returning only the access token would restore at + * most one token lifetime and never the ability to renew headlessly again. */ export async function resyncSub2ApiAuthToken( baseUrl: string, @@ -61,8 +68,15 @@ export async function resyncSub2ApiAuthToken( const accessToken = session?.accessToken?.trim() if (!session || !accessToken) return null + const refreshToken = session.sub2apiAuth?.refreshToken?.trim() + const tokenExpiresAt = session.sub2apiAuth?.tokenExpiresAt + return { accessToken, + ...(refreshToken ? { refreshToken } : {}), + ...(typeof tokenExpiresAt === "number" && Number.isFinite(tokenExpiresAt) + ? { tokenExpiresAt } + : {}), source: mapResyncSource(session.source), } } diff --git a/src/services/checkin/autoCheckin/providers/index.ts b/src/services/checkin/autoCheckin/providers/index.ts index 82303588fb..fa0c87d10f 100644 --- a/src/services/checkin/autoCheckin/providers/index.ts +++ b/src/services/checkin/autoCheckin/providers/index.ts @@ -7,6 +7,7 @@ import type { SiteAccount } from "~/types" import type { TempWindowRequestSource } from "~/types/tempWindowFetch" import { AnyrouterCheckInParams, anyrouterProvider } from "./anyrouter" +import { sub2ApiProvider } from "./sub2api" import { veloeraProvider } from "./veloera" import { wongGongyiProvider } from "./wong" @@ -36,6 +37,7 @@ const providers: Record = { [SITE_TYPES.WONG_GONGYI]: wongGongyiProvider, [SITE_TYPES.NEW_API]: newApiProvider, [SITE_TYPES.VO_API_V2]: voApiV2Provider, + [SITE_TYPES.SUB2API]: sub2ApiProvider, } /** diff --git a/src/services/checkin/autoCheckin/providers/shared.ts b/src/services/checkin/autoCheckin/providers/shared.ts index 3a82bbae86..ced7f8187a 100644 --- a/src/services/checkin/autoCheckin/providers/shared.ts +++ b/src/services/checkin/autoCheckin/providers/shared.ts @@ -14,6 +14,7 @@ export const AUTO_CHECKIN_PROVIDER_FALLBACK_MESSAGE_KEYS = { checkinSuccessful: "autoCheckin:providerFallback.checkinSuccessful", checkinFailed: "autoCheckin:providerFallback.checkinFailed", endpointNotSupported: "autoCheckin:providerFallback.endpointNotSupported", + sub2apiDisabled: "autoCheckin:providerFallback.sub2apiDisabled", unknownError: "autoCheckin:providerFallback.unknownError", } as const diff --git a/src/services/checkin/autoCheckin/providers/sub2api.ts b/src/services/checkin/autoCheckin/providers/sub2api.ts new file mode 100644 index 0000000000..54431db9e6 --- /dev/null +++ b/src/services/checkin/autoCheckin/providers/sub2api.ts @@ -0,0 +1,121 @@ +/** + * Sub2API auto check-in provider. + * + * Source: https://github.com/Wei-Shaw/sub2api + * Check-in is not part of upstream mainline, so the flow is gated behind the + * global Sub2API opt-in and probes both known route pairs before giving up. + * Endpoint selection, response heuristics, and JWT refresh live in + * `~/services/apiService/sub2api`; this provider only maps the outcome onto the + * scheduler's normalized result shape. + */ + +import { accountSub2ApiAuthSession } from "~/services/accounts/sub2apiAuthSession" +import { performSub2ApiCheckin } from "~/services/apiService/sub2api" +import type { Sub2ApiAuthSessionRequest } from "~/services/apiService/sub2api/authSession" +import { + AUTO_CHECKIN_PROVIDER_FALLBACK_MESSAGE_KEYS, + resolveProviderErrorResult, +} from "~/services/checkin/autoCheckin/providers/shared" +import type { AutoCheckinProviderResult } from "~/services/checkin/autoCheckin/providers/types" +import { isSub2ApiCheckinEnabled } from "~/services/checkin/sub2apiCheckinPreference" +import type { SiteAccount } from "~/types" +import { CHECKIN_RESULT_STATUS } from "~/types/autoCheckin" +import { normalizeTempWindowRequestSource } from "~/utils/browser/tempWindowRequestSource" + +import type { AutoCheckinProvider, AutoCheckinProviderContext } from "./index" + +/** + * Perform check-in for a Sub2API account. + */ +async function checkinSub2Api( + account: SiteAccount, + context: AutoCheckinProviderContext, +): Promise { + // Re-check the opt-in here: a stored `enableDetection` can outlive the switch + // being turned off, and the scheduler resolves providers synchronously. + if (!(await isSub2ApiCheckinEnabled())) { + return { + status: CHECKIN_RESULT_STATUS.SKIPPED, + messageKey: AUTO_CHECKIN_PROVIDER_FALLBACK_MESSAGE_KEYS.sub2apiDisabled, + } + } + + const tempWindowRequestSource = normalizeTempWindowRequestSource( + context.tempWindowRequestSource, + ) + + try { + // Sub2API rotates the refresh token on every renewal and invalidates the + // previous one immediately, so a renewal triggered by this run must be + // written back. Without the auth-session port the rotated pair stays in + // memory and the stored refresh token is left permanently dead. + // Upstream contract: https://github.com/Wei-Shaw/sub2api + const request: Sub2ApiAuthSessionRequest = { + baseUrl: account.site_url, + accountId: account.id, + auth: { + authType: account.authType, + userId: account.account_info.id, + accessToken: account.account_info.access_token, + refreshToken: account.sub2apiAuth?.refreshToken, + tokenExpiresAt: account.sub2apiAuth?.tokenExpiresAt, + }, + tempWindowRequestSource, + protectionBypassExecution: context.protectionBypassExecution, + sub2apiAuthSession: accountSub2ApiAuthSession, + } + + const outcome = await performSub2ApiCheckin(request) + + const rawMessage = outcome.message || undefined + + if (outcome.alreadyChecked) { + return { + status: CHECKIN_RESULT_STATUS.ALREADY_CHECKED, + rawMessage, + ...(rawMessage + ? {} + : { + messageKey: + AUTO_CHECKIN_PROVIDER_FALLBACK_MESSAGE_KEYS.alreadyCheckedToday, + }), + } + } + + return { + status: CHECKIN_RESULT_STATUS.SUCCESS, + rawMessage, + ...(rawMessage + ? {} + : { + messageKey: + AUTO_CHECKIN_PROVIDER_FALLBACK_MESSAGE_KEYS.checkinSuccessful, + }), + ...(outcome.reward ? { data: { reward: outcome.reward } } : {}), + } + } catch (error: unknown) { + // Pass the raw error so the shared resolver can read `statusCode` and map an + // unsupported deployment (404) to the "endpoint not supported" copy. + return resolveProviderErrorResult({ error }) + } +} + +/** + * Check whether an account is configured well enough to attempt check-in. + * + * Sub2API only authenticates with a dashboard JWT, so an access token is the + * single hard requirement; the global opt-in is enforced in `checkIn` because + * this predicate is synchronous. + */ +function canCheckIn(account: SiteAccount): boolean { + if (!account.checkIn?.enableDetection) { + return false + } + + return Boolean(account.account_info?.access_token) +} + +export const sub2ApiProvider: AutoCheckinProvider = { + canCheckIn, + checkIn: checkinSub2Api, +} diff --git a/src/services/checkin/sub2apiCheckinPreference.ts b/src/services/checkin/sub2apiCheckinPreference.ts new file mode 100644 index 0000000000..49c34c83fc --- /dev/null +++ b/src/services/checkin/sub2apiCheckinPreference.ts @@ -0,0 +1,34 @@ +/** + * Global opt-in switch for Sub2API daily check-in. + * + * Source: https://github.com/Wei-Shaw/sub2api + * Upstream mainline does not register daily check-in routes; only some + * deployments/forks expose `/api/v1/redeem/checkin` (or the older + * `/api/v1/check-in`). Probing those routes for every Sub2API account would + * produce 404 noise for the majority of users, so the capability stays behind + * an explicit opt-in instead of being detected unconditionally. + */ + +import { + DEFAULT_PREFERENCES, + userPreferences, +} from "~/services/preferences/userPreferences" + +/** + * Resolve whether the user opted into Sub2API check-in. + * + * Preference reads can fail while extension storage is unavailable; treat any + * failure as "disabled" so a storage fault never starts probing unsupported + * deployments. + */ +export async function isSub2ApiCheckinEnabled(): Promise { + try { + const preferences = await userPreferences.getPreferences() + const autoCheckin = + preferences.autoCheckin ?? DEFAULT_PREFERENCES.autoCheckin! + + return autoCheckin.sub2apiEnabled === true + } catch { + return false + } +} diff --git a/src/services/preferences/userPreferences.ts b/src/services/preferences/userPreferences.ts index dc4db02d3e..9cabef35b7 100644 --- a/src/services/preferences/userPreferences.ts +++ b/src/services/preferences/userPreferences.ts @@ -613,6 +613,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = { globalEnabled: true, pretriggerDailyOnUiOpen: false, notifyUiOnCompletion: true, + sub2apiEnabled: false, windowStart: "09:00", windowEnd: "23:00", scheduleMode: AUTO_CHECKIN_SCHEDULE_MODE.RANDOM, diff --git a/src/services/productAnalytics/autoCheckin.ts b/src/services/productAnalytics/autoCheckin.ts index 8a8430c0f4..a5f4a830bc 100644 --- a/src/services/productAnalytics/autoCheckin.ts +++ b/src/services/productAnalytics/autoCheckin.ts @@ -399,6 +399,7 @@ export function buildAutoCheckinConfigSnapshotProperties( setting_id: PRODUCT_ANALYTICS_SETTING_IDS.AutoCheckinConfigSnapshot, entrypoint, global_enabled: preferences.globalEnabled === true, + sub2api_enabled: preferences.sub2apiEnabled === true, ui_pretrigger_enabled: preferences.pretriggerDailyOnUiOpen === true, notify_completion_enabled: preferences.notifyUiOnCompletion !== false, retry_enabled: preferences.retryStrategy?.enabled === true, diff --git a/src/services/productAnalytics/contracts.ts b/src/services/productAnalytics/contracts.ts index e048ed4b65..923ee18bc1 100644 --- a/src/services/productAnalytics/contracts.ts +++ b/src/services/productAnalytics/contracts.ts @@ -1288,6 +1288,7 @@ export type ProductAnalyticsEventPayloadMap = { task_enabled_count?: number notification_enabled?: boolean global_enabled?: boolean + sub2api_enabled?: boolean ui_pretrigger_enabled?: boolean notify_completion_enabled?: boolean retry_enabled?: boolean @@ -1338,6 +1339,7 @@ export type ProductAnalyticsEventPayloadMap = { managed_site_model_sync_allowed_models_configured?: boolean managed_site_model_sync_global_filters_configured?: boolean auto_checkin_global_enabled?: boolean + auto_checkin_sub2api_enabled?: boolean auto_checkin_ui_pretrigger_enabled?: boolean auto_checkin_notify_completion_enabled?: boolean auto_checkin_retry_enabled?: boolean diff --git a/src/services/productAnalytics/privacy.ts b/src/services/productAnalytics/privacy.ts index cc66c3ee5a..3363a58204 100644 --- a/src/services/productAnalytics/privacy.ts +++ b/src/services/productAnalytics/privacy.ts @@ -286,6 +286,7 @@ const EVENT_ALLOWED_KEYS = { "task_enabled_count", "notification_enabled", "global_enabled", + "sub2api_enabled", "ui_pretrigger_enabled", "notify_completion_enabled", "retry_enabled", @@ -410,6 +411,7 @@ const EVENT_ALLOWED_KEYS = { "managed_site_model_sync_allowed_models_configured", "managed_site_model_sync_global_filters_configured", "auto_checkin_global_enabled", + "auto_checkin_sub2api_enabled", "auto_checkin_ui_pretrigger_enabled", "auto_checkin_notify_completion_enabled", "auto_checkin_retry_enabled", diff --git a/src/services/productAnalytics/settings.ts b/src/services/productAnalytics/settings.ts index 983220af47..0fb635c831 100644 --- a/src/services/productAnalytics/settings.ts +++ b/src/services/productAnalytics/settings.ts @@ -852,6 +852,7 @@ export function buildAggregateSettingsSnapshotEvent( managed_site_model_sync_global_filters_configured: managedSiteModelSync.global_filters_configured, auto_checkin_global_enabled: autoCheckin.global_enabled, + auto_checkin_sub2api_enabled: autoCheckin.sub2api_enabled, auto_checkin_ui_pretrigger_enabled: autoCheckin.ui_pretrigger_enabled, auto_checkin_notify_completion_enabled: autoCheckin.notify_completion_enabled, diff --git a/src/types/autoCheckin.ts b/src/types/autoCheckin.ts index 21ec921b84..27fe7e2541 100644 --- a/src/types/autoCheckin.ts +++ b/src/types/autoCheckin.ts @@ -322,6 +322,16 @@ export interface AutoCheckinPreferences { * auto check-in execution so open UI surfaces can refresh the affected accounts immediately. */ notifyUiOnCompletion: boolean + + /** + * Opt-in for Sub2API daily check-in. + * + * Source: https://github.com/Wei-Shaw/sub2api + * Upstream mainline registers no check-in route; only some deployments/forks + * serve `/api/v1/redeem/checkin` (or the older `/api/v1/check-in`). Default + * off so unsupported deployments are never probed. + */ + sub2apiEnabled: boolean windowStart: string // HH:mm format (e.g., "09:00") windowEnd: string // HH:mm format (e.g., "18:00") scheduleMode: AutoCheckinScheduleMode diff --git a/tests/entrypoints/options/AutoCheckinSettings.test.tsx b/tests/entrypoints/options/AutoCheckinSettings.test.tsx index b61d722651..3b5d58f9ff 100644 --- a/tests/entrypoints/options/AutoCheckinSettings.test.tsx +++ b/tests/entrypoints/options/AutoCheckinSettings.test.tsx @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import AutoCheckinSettings from "~/features/BasicSettings/components/tabs/CheckinRedeem/AutoCheckinSettings" +import { AUTO_CHECKIN_SUB2API_TARGET_ID } from "~/features/BasicSettings/components/tabs/CheckinRedeem/targetIds" import { PRODUCT_ANALYTICS_ACTION_IDS, PRODUCT_ANALYTICS_ENTRYPOINTS, @@ -16,6 +17,7 @@ import { render, screen, waitFor, + within, } from "~~/tests/test-utils/render" const { @@ -97,6 +99,7 @@ describe("AutoCheckinSettings", () => { globalEnabled: true, pretriggerDailyOnUiOpen: true, notifyUiOnCompletion: true, + sub2apiEnabled: false, windowStart: "08:00", windowEnd: "10:00", scheduleMode: AUTO_CHECKIN_SCHEDULE_MODE.DETERMINISTIC, @@ -122,6 +125,24 @@ describe("AutoCheckinSettings", () => { }) }) + it("persists the Sub2API check-in opt-in when its switch is toggled", async () => { + render(, { + withUserPreferencesProvider: false, + withThemeProvider: false, + }) + + const sub2apiCard = document.getElementById(AUTO_CHECKIN_SUB2API_TARGET_ID) + expect(sub2apiCard).not.toBeNull() + + fireEvent.click(within(sub2apiCard!).getByRole("switch")) + + await waitFor(() => { + expect(updateAutoCheckin).toHaveBeenCalledWith({ + sub2apiEnabled: true, + }) + }) + }) + it("validates time inputs before saving and reports invalid values", () => { render(, { withUserPreferencesProvider: false, diff --git a/tests/entrypoints/options/AutoCheckinStatusCard.test.tsx b/tests/entrypoints/options/AutoCheckinStatusCard.test.tsx index ddc87e4b5a..64d972ffae 100644 --- a/tests/entrypoints/options/AutoCheckinStatusCard.test.tsx +++ b/tests/entrypoints/options/AutoCheckinStatusCard.test.tsx @@ -12,6 +12,7 @@ describe("AutoCheckin StatusCard scheduling labels", () => { globalEnabled: true, pretriggerDailyOnUiOpen: true, notifyUiOnCompletion: true, + sub2apiEnabled: false, windowStart: "09:00", windowEnd: "18:00", scheduleMode: "random", diff --git a/tests/features/AccountManagement/components/AccountDialog/sitePolicy.test.ts b/tests/features/AccountManagement/components/AccountDialog/sitePolicy.test.ts index dddae124f0..d96176af35 100644 --- a/tests/features/AccountManagement/components/AccountDialog/sitePolicy.test.ts +++ b/tests/features/AccountManagement/components/AccountDialog/sitePolicy.test.ts @@ -183,7 +183,7 @@ describe("Account Dialog site policy", () => { SITE_TYPES.SUB2API, ) expect(sub2apiPolicy.allowCookieAuthSession).toBe(false) - expect(sub2apiPolicy.allowBuiltInCheckInDetection).toBe(false) + expect(sub2apiPolicy.allowBuiltInCheckInDetection).toBe(true) expect(sub2apiPolicy.allowSub2ApiRefreshTokenState).toBe(true) const aihubmixPolicy = getIsolatedSitePolicy(SITE_TYPES.AIHUBMIX) @@ -209,7 +209,7 @@ describe("Account Dialog site policy", () => { cookieAuthSessionCookie: "", checkIn: { ...createEmptyAccountDialogDraft().checkIn, - enableDetection: false, + enableDetection: true, autoCheckInEnabled: false, }, }) @@ -222,7 +222,7 @@ describe("Account Dialog site policy", () => { ).toBe(draft) }) - it("normalizes Sub2API dialogs to access-token auth and inactive built-in check-in", () => { + it("normalizes Sub2API dialogs to access-token auth and supported built-in check-in", () => { const policy = getAccountDialogSitePolicy(SITE_TYPES.SUB2API) const normalized = normalizeAccountDialogDraftForSitePolicy({ draft: createDraft({ siteType: SITE_TYPES.SUB2API }), @@ -231,8 +231,8 @@ describe("Account Dialog site policy", () => { expect(normalized.authType).toBe(AuthTypeEnum.AccessToken) expect(normalized.cookieAuthSessionCookie).toBe("") - expect(normalized.checkIn.enableDetection).toBe(false) - expect(normalized.checkIn.autoCheckInEnabled).toBe(false) + expect(normalized.checkIn.enableDetection).toBe(true) + expect(normalized.checkIn.autoCheckInEnabled).toBe(true) expect(normalized.sub2apiUseRefreshToken).toBe(true) expect(normalized.sub2apiRefreshToken).toBe(" refresh-token ") expect(normalized.sub2apiTokenExpiresAt).toBe(123456) diff --git a/tests/features/AccountManagement/components/AccountDialogForm.test.tsx b/tests/features/AccountManagement/components/AccountDialogForm.test.tsx index cbe7fe1762..fb3fd4f067 100644 --- a/tests/features/AccountManagement/components/AccountDialogForm.test.tsx +++ b/tests/features/AccountManagement/components/AccountDialogForm.test.tsx @@ -684,17 +684,15 @@ describe("AccountDialog AccountForm", () => { "accountDialog:form.sub2apiRefreshTokenWarningTitle", ), ).toBeInTheDocument() + // Sub2API exposes built-in check-in detection (gated at runtime by the + // global opt-in), so the check-in section renders instead of the + // unsupported copy. expect( - screen.getByText("accountDialog:form.checkInStatusUnsupported", { - exact: false, - }), + screen.getByTestId(ACCOUNT_MANAGEMENT_TEST_IDS.accountFormSectionCheckIn), ).toBeInTheDocument() expect( - screen.queryByText("accountDialog:form.checkInStatusDesc"), - ).not.toBeInTheDocument() - expect( - screen.queryByRole("switch", { - name: "accountDialog:form.checkInStatus", + screen.queryByText("accountDialog:form.checkInStatusUnsupported", { + exact: false, }), ).not.toBeInTheDocument() expect(screen.getByDisplayValue("formatted-expiry")).toBeDisabled() diff --git a/tests/features/AccountManagement/hooks/useAccountDialog.redetectPreservesCustomData.test.tsx b/tests/features/AccountManagement/hooks/useAccountDialog.redetectPreservesCustomData.test.tsx index 28b8bc7115..36d622aee4 100644 --- a/tests/features/AccountManagement/hooks/useAccountDialog.redetectPreservesCustomData.test.tsx +++ b/tests/features/AccountManagement/hooks/useAccountDialog.redetectPreservesCustomData.test.tsx @@ -348,7 +348,7 @@ describe("useAccountDialog re-detect preservation", () => { } }) - it("forces detected Sub2API accounts back to JWT auth, keeps refresh-token mode opt-in, and disables built-in check-in", async () => { + it("forces detected Sub2API accounts back to JWT auth, keeps refresh-token mode opt-in, and preserves supported check-in", async () => { mockAutoDetectAccount.mockResolvedValueOnce({ success: true, message: "ok", @@ -411,8 +411,8 @@ describe("useAccountDialog re-detect preservation", () => { expect(result.current.state.siteType).toBe("sub2api") expect(result.current.state.authType).toBe(AuthTypeEnum.AccessToken) expect(result.current.state.cookieAuthSessionCookie).toBe("") - expect(result.current.state.checkIn.enableDetection).toBe(false) - expect(result.current.state.checkIn.autoCheckInEnabled).toBe(false) + expect(result.current.state.checkIn.enableDetection).toBe(true) + expect(result.current.state.checkIn.autoCheckInEnabled).toBe(true) expect(result.current.state.sub2apiUseRefreshToken).toBe(false) expect(result.current.state.sub2apiRefreshToken).toBe("refresh-token") expect(result.current.state.sub2apiTokenExpiresAt).toBe(123456789) diff --git a/tests/features/AccountManagement/hooks/useAccountDialog.sub2apiConstraints.test.tsx b/tests/features/AccountManagement/hooks/useAccountDialog.sub2apiConstraints.test.tsx index 4088a67e99..880e26909d 100644 --- a/tests/features/AccountManagement/hooks/useAccountDialog.sub2apiConstraints.test.tsx +++ b/tests/features/AccountManagement/hooks/useAccountDialog.sub2apiConstraints.test.tsx @@ -109,7 +109,7 @@ describe("useAccountDialog Sub2API constraints", () => { ;(globalThis.browser.tabs.sendMessage as any) = vi.fn() }) - it("forces Sub2API dialogs back to JWT auth, clears cookie sessions, and disables built-in check-in", async () => { + it("forces Sub2API dialogs back to JWT auth, clears cookie sessions, and preserves supported check-in", async () => { const { result } = renderHook(() => useAccountDialog({ mode: DIALOG_MODES.ADD, @@ -137,8 +137,8 @@ describe("useAccountDialog Sub2API constraints", () => { await waitFor(() => { expect(result.current.state.authType).toBe(AuthTypeEnum.AccessToken) expect(result.current.state.cookieAuthSessionCookie).toBe("") - expect(result.current.state.checkIn.enableDetection).toBe(false) - expect(result.current.state.checkIn.autoCheckInEnabled).toBe(false) + expect(result.current.state.checkIn.enableDetection).toBe(true) + expect(result.current.state.checkIn.autoCheckInEnabled).toBe(true) }) }) diff --git a/tests/features/AutoCheckin/utils/autoCheckin.test.ts b/tests/features/AutoCheckin/utils/autoCheckin.test.ts index eaf3efe11d..9816160c90 100644 --- a/tests/features/AutoCheckin/utils/autoCheckin.test.ts +++ b/tests/features/AutoCheckin/utils/autoCheckin.test.ts @@ -15,6 +15,7 @@ describe("autoCheckin utils", () => { "autoCheckin:providerFallback.checkinSuccessful", "autoCheckin:providerFallback.checkinFailed", "autoCheckin:providerFallback.endpointNotSupported", + "autoCheckin:providerFallback.sub2apiDisabled", "autoCheckin:providerFallback.nativePageIdentityMismatch", "autoCheckin:providerFallback.nativePageIdentityMissing", "autoCheckin:providerFallback.nativePageStatusUnconfirmed", diff --git a/tests/services/accountSiteDefinitions/registry.test.ts b/tests/services/accountSiteDefinitions/registry.test.ts index 49eb03b4b5..348d285157 100644 --- a/tests/services/accountSiteDefinitions/registry.test.ts +++ b/tests/services/accountSiteDefinitions/registry.test.ts @@ -598,7 +598,7 @@ describe("account site definition registry", () => { defaultAuthType: AuthTypeEnum.AccessToken, defaultAuthHostnames: [], supportsCookieAuth: false, - supportsBuiltInCheckInDetection: false, + supportsBuiltInCheckInDetection: true, }, authSession: { kind: ACCOUNT_SITE_SUPPLEMENTAL_AUTH_KINDS.Sub2ApiRefreshToken, diff --git a/tests/services/accounts/accountSiteProfile.test.ts b/tests/services/accounts/accountSiteProfile.test.ts index b2f5189541..ea285b0a5a 100644 --- a/tests/services/accounts/accountSiteProfile.test.ts +++ b/tests/services/accounts/accountSiteProfile.test.ts @@ -103,7 +103,9 @@ describe("accountSiteProfile", () => { expect(profile.identity.usernameRequired).toBe(false) expect(profile.auth.allowedAuthTypes).toEqual([AuthTypeEnum.AccessToken]) expect(profile.auth.supportsCookieAuth).toBe(false) - expect(profile.auth.supportsBuiltInCheckInDetection).toBe(false) + // Check-in is fork-only upstream: the capability is exposed here and gated + // at runtime by the global Sub2API check-in opt-in. + expect(profile.auth.supportsBuiltInCheckInDetection).toBe(true) expect(profile.supplementalAuth.kind).toBe( ACCOUNT_SITE_SUPPLEMENTAL_AUTH_KINDS.Sub2ApiRefreshToken, ) diff --git a/tests/services/apiService/sub2api/checkin.test.ts b/tests/services/apiService/sub2api/checkin.test.ts new file mode 100644 index 0000000000..25030f5f0a --- /dev/null +++ b/tests/services/apiService/sub2api/checkin.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + extractSub2ApiCheckinMessage, + isSub2ApiAlreadyCheckedError, + isSub2ApiAlreadyCheckedMessage, + isSub2ApiMissingRouteError, + parseSub2ApiCheckinPayload, + SUB2API_CHECKIN_ROUTES, +} from "~/services/apiService/sub2api/checkin" +import { API_ERROR_CODES, ApiError } from "~/services/apiTransport/errors" + +const createApiError = (statusCode: number, message = "boom") => + new ApiError( + message, + statusCode, + "/api/v1/redeem/checkin", + API_ERROR_CODES.HTTP_OTHER, + ) + +afterEach(() => { + vi.useRealTimers() +}) + +describe("sub2api check-in routes", () => { + // The `/api/v1/check-in` pair is the only one observed on a live deployment, + // so it must stay first to avoid wasting a 404 on every probe. + it("probes the observed check-in pair before the redeem-scoped pair", () => { + expect(SUB2API_CHECKIN_ROUTES.map((route) => route.statusEndpoint)).toEqual( + ["/api/v1/check-in/status", "/api/v1/redeem/checkin/status"], + ) + expect( + SUB2API_CHECKIN_ROUTES.map((route) => route.checkinEndpoint), + ).toEqual(["/api/v1/check-in", "/api/v1/redeem/checkin"]) + }) +}) + +describe("parseSub2ApiCheckinPayload", () => { + it("reads an explicit boolean flag through the envelope", () => { + const payload = parseSub2ApiCheckinPayload({ + code: 0, + message: "ok", + data: { checked_in_today: true }, + }) + + expect(payload.isCheckedInToday).toBe(true) + }) + + it("finds the flag no matter how deeply the deployment nests it", () => { + const payload = parseSub2ApiCheckinPayload({ + code: 0, + message: "", + data: { data: { status: { has_checked_in: true } } }, + }) + + expect(payload.isCheckedInToday).toBe(true) + }) + + it("locates a flag inside array payloads", () => { + // `findValue` recurses into arrays; some deployments wrap the flag in an + // array of objects rather than a plain envelope. + expect( + parseSub2ApiCheckinPayload({ + data: [{ id: 1 }, { checked_in_today: true }], + }).isCheckedInToday, + ).toBe(true) + }) + + it("treats a numeric flag as a boolean", () => { + expect( + parseSub2ApiCheckinPayload({ data: { is_checked_in: 1 } }) + .isCheckedInToday, + ).toBe(true) + expect( + parseSub2ApiCheckinPayload({ data: { is_checked_in: 0 } }) + .isCheckedInToday, + ).toBe(false) + }) + + it("falls back to comparing the last check-in date with today", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-07-29T12:00:00Z")) + const today = new Date().toISOString().slice(0, 10) + + expect( + parseSub2ApiCheckinPayload({ + data: { last_checkin_at: `${today}T04:12:00Z` }, + }).isCheckedInToday, + ).toBe(true) + expect( + parseSub2ApiCheckinPayload({ data: { last_checkin_at: "2000-01-01" } }) + .isCheckedInToday, + ).toBe(false) + }) + + it("falls back to backend copy when no flag or date is reported", () => { + expect( + parseSub2ApiCheckinPayload({ message: "今日已签到" }).isCheckedInToday, + ).toBe(true) + expect( + parseSub2ApiCheckinPayload({ message: "签到成功" }).isCheckedInToday, + ).toBe(false) + }) + + it("extracts the awarded amount and ignores non-scalar values", () => { + expect(parseSub2ApiCheckinPayload({ data: { reward: 5 } }).reward).toBe("5") + expect( + parseSub2ApiCheckinPayload({ data: { quota_awarded: "0.5" } }).reward, + ).toBe("0.5") + // A non-scalar hit stops the lookup instead of digging into it, so an + // unexpected shape reports "no reward" rather than "[object Object]". + expect( + parseSub2ApiCheckinPayload({ data: { reward: { amount: 5 } } }).reward, + ).toBe("") + expect(parseSub2ApiCheckinPayload({ data: {} }).reward).toBe("") + }) + + it("tolerates empty and non-object payloads", () => { + for (const body of [null, undefined, "", 42, []]) { + const payload = parseSub2ApiCheckinPayload(body) + expect(payload).toEqual({ + isCheckedInToday: false, + reward: "", + message: "", + }) + } + }) +}) + +describe("extractSub2ApiCheckinMessage", () => { + it("prefers the envelope message and falls back to nested data", () => { + expect(extractSub2ApiCheckinMessage({ message: " done " })).toBe("done") + expect(extractSub2ApiCheckinMessage({ data: { detail: "nested" } })).toBe( + "nested", + ) + expect(extractSub2ApiCheckinMessage({ message: " " })).toBe("") + }) +}) + +describe("isSub2ApiAlreadyCheckedMessage", () => { + it("matches both Chinese and English phrasings", () => { + expect(isSub2ApiAlreadyCheckedMessage("您已签到")).toBe(true) + expect(isSub2ApiAlreadyCheckedMessage("Already checked in today")).toBe( + true, + ) + expect(isSub2ApiAlreadyCheckedMessage("重复签到")).toBe(true) + expect(isSub2ApiAlreadyCheckedMessage("check-in failed")).toBe(false) + }) +}) + +describe("isSub2ApiMissingRouteError", () => { + it("recognizes only missing-route statuses", () => { + expect(isSub2ApiMissingRouteError(createApiError(404))).toBe(true) + expect(isSub2ApiMissingRouteError(createApiError(405))).toBe(true) + expect(isSub2ApiMissingRouteError(createApiError(401))).toBe(false) + expect(isSub2ApiMissingRouteError(createApiError(500))).toBe(false) + expect(isSub2ApiMissingRouteError(new Error("404"))).toBe(false) + }) +}) + +describe("isSub2ApiAlreadyCheckedError", () => { + it("treats HTTP 409 as a repeated check-in", () => { + expect(isSub2ApiAlreadyCheckedError(createApiError(409))).toBe(true) + }) + + it("falls back to message matching for other errors", () => { + expect( + isSub2ApiAlreadyCheckedError(createApiError(400, "今日已签到")), + ).toBe(true) + expect(isSub2ApiAlreadyCheckedError(new Error("already checked"))).toBe( + true, + ) + expect(isSub2ApiAlreadyCheckedError(createApiError(400, "配额不足"))).toBe( + false, + ) + expect(isSub2ApiAlreadyCheckedError("already checked")).toBe(false) + }) +}) diff --git a/tests/services/apiService/sub2api/index.test.ts b/tests/services/apiService/sub2api/index.test.ts index 1f79cde22b..a2d38ba6ff 100644 --- a/tests/services/apiService/sub2api/index.test.ts +++ b/tests/services/apiService/sub2api/index.test.ts @@ -24,6 +24,7 @@ import { fetchUserInfo, getOrCreateAccessToken, markSub2ApiAnnouncementRead, + performSub2ApiCheckin, refreshAccountData, updateApiToken, } from "~/services/apiService/sub2api" @@ -62,6 +63,19 @@ const { mockGetLatestAuth, mockPersistAuthUpdate } = vi.hoisted(() => ({ mockPersistAuthUpdate: vi.fn(), })) +const { mockIsSub2ApiCheckinEnabled } = vi.hoisted(() => ({ + mockIsSub2ApiCheckinEnabled: vi.fn(), +})) + +vi.mock("~/services/checkin/sub2apiCheckinPreference", () => ({ + isSub2ApiCheckinEnabled: mockIsSub2ApiCheckinEnabled, +})) + +// Most tests in this file exercise Sub2API with check-in opt-in off (the +// upstream default), so the probe never runs and no extra `fetchApi` calls are +// made. The opt-in-on describe block at the end flips this back on. +mockIsSub2ApiCheckinEnabled.mockResolvedValue(false) + vi.mock("~/services/accounts/accountHealth", () => ({ determineHealthStatus: vi.fn(() => ({ status: SiteHealthStatus.Unknown, @@ -1249,6 +1263,71 @@ describe("apiService sub2api refreshAccountData", () => { expect(result.authUpdate?.username).toBe("bob") }) + // Sub2API rotates refresh tokens single-use, so the stored one is normally + // dead once re-sync is reached. Persisting the site's current refresh token + // is what restores headless renewal for the next run. + it("persists the re-synced refresh token alongside the access token", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 1, + message: "invalid_refresh_token", + data: null, + }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ), + ) + vi.stubGlobal("fetch", fetchMock as any) + + vi.mocked(fetchApi) + .mockRejectedValueOnce( + new ApiError("Unauthorized", 401, "/api/v1/auth/me"), + ) + .mockResolvedValueOnce({ + code: 0, + message: "ok", + data: { id: 2, username: "bob", balance: 1 }, + } as any) + + vi.mocked(resyncSub2ApiAuthToken).mockResolvedValueOnce({ + accessToken: "resynced-jwt", + refreshToken: "resynced-refresh", + tokenExpiresAt: 1_700_000_060_000, + source: ACCOUNT_BROWSER_SESSION_SOURCES.TEMP_WINDOW, + }) + + const request = createRequest({ + accountId: "account-1", + sub2apiAuthSession: { + getLatestAuth: (...args: any[]) => mockGetLatestAuth(...args), + persistAuthUpdate: (...args: any[]) => mockPersistAuthUpdate(...args), + }, + auth: { + authType: AuthTypeEnum.AccessToken, + userId: "1", + accessToken: "old-jwt", + refreshToken: "old-refresh", + }, + }) + + const result = await refreshAccountData(request) + + expect(result.success).toBe(true) + expect(mockPersistAuthUpdate).toHaveBeenCalledWith("account-1", { + accessToken: "resynced-jwt", + refreshToken: "resynced-refresh", + tokenExpiresAt: 1_700_000_060_000, + }) + // The retried request must carry the re-synced refresh token so a later + // renewal in the same run does not reuse the dead one. + expect((vi.mocked(fetchApi).mock.calls[1]?.[0] as any)?.auth).toMatchObject( + { + accessToken: "resynced-jwt", + refreshToken: "resynced-refresh", + }, + ) + }) + it("returns restore-required warning when refresh token restore and re-sync both fail", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( @@ -2652,3 +2731,229 @@ describe("apiService sub2api exported operations", () => { }) }) }) + +describe("apiService sub2api check-in with the global opt-in on", () => { + beforeEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.clearAllMocks() + vi.mocked(fetchApi).mockReset() + vi.mocked(resyncSub2ApiAuthToken).mockReset() + mockGetLatestAuth.mockReset() + mockPersistAuthUpdate.mockReset() + mockGetLatestAuth.mockResolvedValue(null) + mockPersistAuthUpdate.mockResolvedValue(true) + mockIsSub2ApiCheckinEnabled.mockResolvedValue(true) + }) + + const request = { + baseUrl: "https://sub2.example.com", + accountId: "account-1", + auth: { + authType: AuthTypeEnum.AccessToken, + userId: "1", + accessToken: "jwt-token", + }, + } as const + + const statusProbeBody = { + code: 0, + message: "", + data: { checked_in_today: false }, + } + + const currentUserBody = { + code: 0, + message: "ok", + data: { id: 1, username: "alice", balance: 2 }, + } + + const todayUsageBody = { + code: 0, + message: "ok", + data: { + total_requests: 2, + total_input_tokens: 12, + total_output_tokens: 8, + total_actual_cost: 0.25, + }, + } + + const mockFetchApiByEndpoint = (bodies: Record) => { + // Match by substring (usage carries a `?period=today` query), longest key + // first so `/api/v1/check-in/status` wins over `/api/v1/check-in`. + const keys = Object.keys(bodies).sort((a, b) => b.length - a.length) + + vi.mocked(fetchApi).mockImplementation( + async (_authRequest: unknown, spec: { endpoint?: string }) => { + const endpoint = spec?.endpoint ?? "" + const match = keys.find((key) => endpoint.includes(key)) + if (match) { + return bodies[match] + } + throw new Error(`unexpected endpoint: ${endpoint}`) + }, + ) + } + + it("reports support when the probe finds a check-in route", async () => { + mockFetchApiByEndpoint({ + "/api/v1/check-in/status": statusProbeBody, + }) + + await expect(fetchSupportCheckIn(request as any)).resolves.toBe(true) + }) + + it("reports unknown support when the probe fails for an unrelated reason", async () => { + vi.mocked(fetchApi).mockRejectedValueOnce( + new ApiError( + "upstream down", + 500, + "/api/v1/check-in/status", + API_ERROR_CODES.HTTP_OTHER, + ), + ) + + await expect(fetchSupportCheckIn(request as any)).resolves.toBeUndefined() + }) + + it("reports no support when every check-in route is missing", async () => { + vi.mocked(fetchApi) + .mockRejectedValueOnce( + new ApiError( + "not found", + 404, + "/api/v1/check-in/status", + API_ERROR_CODES.HTTP_OTHER, + ), + ) + .mockRejectedValueOnce( + new ApiError( + "not found", + 404, + "/api/v1/redeem/checkin/status", + API_ERROR_CODES.HTTP_OTHER, + ), + ) + + await expect(fetchSupportCheckIn(request as any)).resolves.toBe(false) + }) + + it("resolves undefined when no check-in route exists", async () => { + vi.mocked(fetchApi) + .mockRejectedValueOnce( + new ApiError( + "not found", + 404, + "/api/v1/check-in/status", + API_ERROR_CODES.HTTP_OTHER, + ), + ) + .mockRejectedValueOnce( + new ApiError( + "not found", + 404, + "/api/v1/redeem/checkin/status", + API_ERROR_CODES.HTTP_OTHER, + ), + ) + + await expect(fetchCheckInStatus(request as any)).resolves.toBeUndefined() + }) + + it("resolves can-check-in from a not-yet-checked probe", async () => { + mockFetchApiByEndpoint({ + "/api/v1/check-in/status": statusProbeBody, + }) + + await expect(fetchCheckInStatus(request as any)).resolves.toBe(true) + }) + + it("resolves undefined when the status probe fails", async () => { + vi.mocked(fetchApi).mockRejectedValueOnce( + new ApiError( + "upstream down", + 500, + "/api/v1/check-in/status", + API_ERROR_CODES.HTTP_OTHER, + ), + ) + + await expect(fetchCheckInStatus(request as any)).resolves.toBeUndefined() + }) + + it("treats an already-checked-in probe error as proof the route exists", async () => { + vi.mocked(fetchApi).mockRejectedValueOnce( + new ApiError( + "已签到", + 409, + "/api/v1/check-in/status", + API_ERROR_CODES.HTTP_OTHER, + ), + ) + + const outcome = await performSub2ApiCheckin(request as any) + + expect(outcome.alreadyChecked).toBe(true) + }) + + it("rethrows a non-already-checked failure from the check-in post", async () => { + vi.mocked(fetchApi) + .mockResolvedValueOnce(statusProbeBody) + .mockRejectedValueOnce( + new ApiError( + "quota exhausted", + 400, + "/api/v1/check-in", + API_ERROR_CODES.HTTP_OTHER, + ), + ) + + await expect(performSub2ApiCheckin(request as any)).rejects.toThrow( + "quota exhausted", + ) + }) + + it("runs the support probe when account detection is enabled", async () => { + mockFetchApiByEndpoint({ + "/api/v1/check-in/status": statusProbeBody, + "/api/v1/auth/me": currentUserBody, + "/api/v1/usage/stats": todayUsageBody, + }) + + const result = await fetchAccountData({ + ...request, + checkIn: { + enableDetection: true, + autoCheckInEnabled: true, + siteStatus: { isCheckedInToday: false }, + customCheckIn: { url: "", redeemUrl: "", openRedeemWithCheckIn: true }, + }, + } as any) + + expect(result.checkIn.enableDetection).toBe(true) + // Probe says not checked in yet, so the site can still check in today. + expect(result.checkIn.siteStatus?.isCheckedInToday).toBe(false) + }) + + it("skips the support probe when account detection is disabled", async () => { + mockFetchApiByEndpoint({ + "/api/v1/auth/me": currentUserBody, + "/api/v1/usage/stats": todayUsageBody, + }) + + const result = await fetchAccountData({ + ...request, + checkIn: { + enableDetection: false, + autoCheckInEnabled: true, + siteStatus: { isCheckedInToday: true }, + customCheckIn: { url: "", redeemUrl: "", openRedeemWithCheckIn: true }, + }, + } as any) + + expect(result.checkIn.enableDetection).toBe(false) + // No probe was sent, so the previously-known status is preserved. + expect(result.checkIn.siteStatus?.isCheckedInToday).toBe(true) + }) +}) diff --git a/tests/services/apiService/sub2api/tokenResync.test.ts b/tests/services/apiService/sub2api/tokenResync.test.ts index 22b9120cb8..2d6f4fa12a 100644 --- a/tests/services/apiService/sub2api/tokenResync.test.ts +++ b/tests/services/apiService/sub2api/tokenResync.test.ts @@ -120,6 +120,54 @@ describe("Sub2API token re-sync", () => { ).resolves.toBeNull() }) + // Sub2API rotates refresh tokens single-use, so the stored one is usually + // already dead by the time re-sync runs. Carrying the site's current refresh + // token back is what restores headless renewal instead of buying one more + // access-token lifetime. + it("carries the re-synced refresh token and expiry back to the caller", async () => { + mockResolveAccountBrowserSession.mockResolvedValueOnce({ + source: ACCOUNT_BROWSER_SESSION_SOURCES.TEMP_WINDOW, + siteType: "sub2api", + userId: "42", + user: { username: "temp-user" }, + accessToken: " temp-window-token ", + sub2apiAuth: { + refreshToken: " live-refresh ", + tokenExpiresAt: 1_700_000_060_000, + }, + }) + + await expect( + resyncSub2ApiAuthToken("https://sub2.example.com"), + ).resolves.toEqual({ + accessToken: "temp-window-token", + refreshToken: "live-refresh", + tokenExpiresAt: 1_700_000_060_000, + source: ACCOUNT_BROWSER_SESSION_SOURCES.TEMP_WINDOW, + }) + }) + + it("omits unusable re-synced refresh-token metadata", async () => { + mockResolveAccountBrowserSession.mockResolvedValueOnce({ + source: ACCOUNT_BROWSER_SESSION_SOURCES.EXISTING_TAB, + siteType: "sub2api", + userId: "42", + user: { username: "tab-user" }, + accessToken: "tab-token", + sub2apiAuth: { + refreshToken: " ", + tokenExpiresAt: Number.NaN, + }, + }) + + await expect( + resyncSub2ApiAuthToken("https://sub2.example.com"), + ).resolves.toEqual({ + accessToken: "tab-token", + source: ACCOUNT_BROWSER_SESSION_SOURCES.EXISTING_TAB, + }) + }) + it("passes a usability predicate that accepts only non-empty access tokens", async () => { mockResolveAccountBrowserSession.mockImplementationOnce( async ({ isUsableSession }) => { diff --git a/tests/services/autoCheckin/providers/sub2api.test.ts b/tests/services/autoCheckin/providers/sub2api.test.ts new file mode 100644 index 0000000000..7f00e18731 --- /dev/null +++ b/tests/services/autoCheckin/providers/sub2api.test.ts @@ -0,0 +1,318 @@ +import { http, HttpResponse } from "msw" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { SITE_TYPES } from "~/constants/siteType" +import { + resolveAutoCheckinProvider, + type AutoCheckinProvider, +} from "~/services/checkin/autoCheckin/providers" +import { sub2ApiProvider } from "~/services/checkin/autoCheckin/providers/sub2api" +import { PROTECTION_BYPASS_USER_COMMANDS } from "~/services/protectionBypass/contracts" +import { AuthTypeEnum, type SiteAccount } from "~/types" +import { CHECKIN_RESULT_STATUS } from "~/types/autoCheckin" +import { TEMP_WINDOW_REQUEST_SOURCES } from "~/types/tempWindowFetch" +import { server } from "~~/tests/msw/server" +import { userCommandExecution } from "~~/tests/services/protectionBypass/fixtures" + +const { mockIsSub2ApiCheckinEnabled } = vi.hoisted(() => ({ + mockIsSub2ApiCheckinEnabled: vi.fn(), +})) + +vi.mock("~/services/checkin/sub2apiCheckinPreference", () => ({ + isSub2ApiCheckinEnabled: mockIsSub2ApiCheckinEnabled, +})) + +const { getAccountByIdMock, updateAccountMock } = vi.hoisted(() => ({ + getAccountByIdMock: vi.fn(), + updateAccountMock: vi.fn(), +})) + +vi.mock("~/services/accounts/accountStorage", () => ({ + accountStorage: { + getAccountById: (...args: unknown[]) => getAccountByIdMock(...args), + updateAccount: (...args: unknown[]) => updateAccountMock(...args), + }, +})) + +const SITE_URL = "https://sub2api.invalid" +// Primary = the pair observed on a live deployment; fallback = the +// redeem-scoped pair kept for forks that register check-in elsewhere. +const PRIMARY_STATUS_URL = `${SITE_URL}/api/v1/check-in/status` +const PRIMARY_CHECKIN_URL = `${SITE_URL}/api/v1/check-in` +const FALLBACK_STATUS_URL = `${SITE_URL}/api/v1/redeem/checkin/status` +const FALLBACK_CHECKIN_URL = `${SITE_URL}/api/v1/redeem/checkin` +const REFRESH_URL = `${SITE_URL}/api/v1/auth/refresh` + +const createAccount = (overrides: Partial = {}): SiteAccount => + ({ + id: "account-1", + site_type: SITE_TYPES.SUB2API, + site_url: SITE_URL, + authType: AuthTypeEnum.AccessToken, + account_info: { + id: "7", + access_token: "jwt-dashboard", + }, + checkIn: { + enableDetection: true, + }, + ...overrides, + }) as unknown as SiteAccount + +const envelope = (data: unknown, message = "") => ({ + code: 0, + message, + data, +}) + +const DEFAULT_PROVIDER_CONTEXT = { + tempWindowRequestSource: TEMP_WINDOW_REQUEST_SOURCES.Background, + protectionBypassExecution: userCommandExecution( + PROTECTION_BYPASS_USER_COMMANDS.ManualCheckin, + ), +} as const + +const checkInForTest = ( + account: Parameters[0], + context: Parameters< + typeof sub2ApiProvider.checkIn + >[1] = DEFAULT_PROVIDER_CONTEXT, +) => sub2ApiProvider.checkIn(account, context) + +describe("sub2ApiProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsSub2ApiCheckinEnabled.mockResolvedValue(true) + getAccountByIdMock.mockResolvedValue(null) + updateAccountMock.mockResolvedValue(true) + }) + + it("registers the Sub2API auto-check-in provider", () => { + expect(resolveAutoCheckinProvider(createAccount())).toBe( + sub2ApiProvider as AutoCheckinProvider, + ) + }) + + describe("canCheckIn", () => { + it("requires detection to be enabled and a dashboard token", () => { + expect(sub2ApiProvider.canCheckIn(createAccount())).toBe(true) + expect( + sub2ApiProvider.canCheckIn( + createAccount({ checkIn: { enableDetection: false } }), + ), + ).toBe(false) + expect( + sub2ApiProvider.canCheckIn( + createAccount({ + account_info: { id: "7", access_token: "" }, + } as Partial), + ), + ).toBe(false) + }) + }) + + it("skips without any request while the global opt-in is off", async () => { + mockIsSub2ApiCheckinEnabled.mockResolvedValue(false) + const statusHandler = vi.fn() + server.use( + http.get(PRIMARY_STATUS_URL, () => { + statusHandler() + return HttpResponse.json(envelope({ checked_in_today: false })) + }), + ) + + const result = await checkInForTest(createAccount()) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.SKIPPED) + expect(result.messageKey).toBe( + "autoCheckin:providerFallback.sub2apiDisabled", + ) + expect(statusHandler).not.toHaveBeenCalled() + }) + + it("checks in through the primary route", async () => { + const checkinHandler = vi.fn() + server.use( + http.get(PRIMARY_STATUS_URL, () => + HttpResponse.json(envelope({ checked_in_today: false })), + ), + http.post(PRIMARY_CHECKIN_URL, ({ request }) => { + checkinHandler(request.headers.get("authorization")) + return HttpResponse.json( + envelope({ quota_awarded: "0.5" }, "签到成功,获得 0.5"), + ) + }), + ) + + const result = await checkInForTest(createAccount()) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.SUCCESS) + expect(result.rawMessage).toBe("签到成功,获得 0.5") + expect(result.data).toEqual({ reward: "0.5" }) + expect(checkinHandler).toHaveBeenCalledWith("Bearer jwt-dashboard") + }) + + it("falls back to the local success copy when the backend returns no message or reward", async () => { + server.use( + http.get(PRIMARY_STATUS_URL, () => + HttpResponse.json(envelope({ checked_in_today: false })), + ), + http.post(PRIMARY_CHECKIN_URL, () => HttpResponse.json(envelope({}))), + ) + + const result = await checkInForTest(createAccount()) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.SUCCESS) + expect(result.messageKey).toBe( + "autoCheckin:providerFallback.checkinSuccessful", + ) + expect(result.rawMessage).toBeUndefined() + expect(result.data).toBeUndefined() + }) + + it("falls back to the redeem-scoped route when the primary route is missing", async () => { + const fallbackCheckin = vi.fn() + server.use( + http.get( + PRIMARY_STATUS_URL, + () => new HttpResponse(null, { status: 404 }), + ), + http.get(FALLBACK_STATUS_URL, () => + HttpResponse.json(envelope({ checked_in_today: false })), + ), + http.post(FALLBACK_CHECKIN_URL, () => { + fallbackCheckin() + return HttpResponse.json(envelope({ reward: 1 }, "ok")) + }), + ) + + const result = await checkInForTest(createAccount()) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.SUCCESS) + expect(fallbackCheckin).toHaveBeenCalledTimes(1) + }) + + it("reports an unsupported deployment when neither route exists", async () => { + server.use( + http.get( + PRIMARY_STATUS_URL, + () => new HttpResponse(null, { status: 404 }), + ), + http.get( + FALLBACK_STATUS_URL, + () => new HttpResponse(null, { status: 404 }), + ), + ) + + const result = await checkInForTest(createAccount()) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.FAILED) + expect(result.messageKey).toBe( + "autoCheckin:providerFallback.endpointNotSupported", + ) + }) + + it("does not post a second check-in when the status route already reports today", async () => { + const checkinHandler = vi.fn() + server.use( + http.get(PRIMARY_STATUS_URL, () => + HttpResponse.json(envelope({ checked_in_today: true })), + ), + http.post(PRIMARY_CHECKIN_URL, () => { + checkinHandler() + return HttpResponse.json(envelope({})) + }), + ) + + const result = await checkInForTest(createAccount()) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.ALREADY_CHECKED) + expect(checkinHandler).not.toHaveBeenCalled() + }) + + it("maps an HTTP 409 from the check-in route to already-checked", async () => { + server.use( + http.get(PRIMARY_STATUS_URL, () => + HttpResponse.json(envelope({ checked_in_today: false })), + ), + http.post( + PRIMARY_CHECKIN_URL, + () => new HttpResponse(null, { status: 409 }), + ), + ) + + const result = await checkInForTest(createAccount()) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.ALREADY_CHECKED) + }) + + it("surfaces an unexpected upstream failure as a failed result", async () => { + server.use( + http.get( + PRIMARY_STATUS_URL, + () => new HttpResponse(null, { status: 500 }), + ), + ) + + const result = await checkInForTest(createAccount()) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.FAILED) + }) + + // Sub2API invalidates a refresh token the moment it is exchanged, so a + // renewal triggered by a background check-in must reach storage. Dropping it + // leaves the stored token dead and forces re-authorization the next day. + it("persists the rotated token pair when check-in triggers a refresh", async () => { + const expiringAccount = createAccount({ + account_info: { id: "7", access_token: "expiring-jwt" }, + sub2apiAuth: { + refreshToken: "stored-refresh", + // Already past expiry, so the proactive refresh path runs. + tokenExpiresAt: Date.now() - 1_000, + }, + } as Partial) + + getAccountByIdMock.mockResolvedValue({ + account_info: expiringAccount.account_info, + sub2apiAuth: expiringAccount.sub2apiAuth, + }) + + const checkinAuthHeader = vi.fn() + server.use( + http.post(REFRESH_URL, async ({ request }) => { + const body = (await request.json()) as { refresh_token?: string } + expect(body.refresh_token).toBe("stored-refresh") + return HttpResponse.json( + envelope({ + access_token: "rotated-jwt", + refresh_token: "rotated-refresh", + expires_in: 86_400, + }), + ) + }), + http.get(PRIMARY_STATUS_URL, () => + HttpResponse.json(envelope({ checked_in_today: false })), + ), + http.post(PRIMARY_CHECKIN_URL, ({ request }) => { + checkinAuthHeader(request.headers.get("authorization")) + return HttpResponse.json(envelope({ quota_awarded: "1" }, "签到成功")) + }), + ) + + const result = await checkInForTest(expiringAccount) + + expect(result.status).toBe(CHECKIN_RESULT_STATUS.SUCCESS) + // The check-in itself must use the rotated access token. + expect(checkinAuthHeader).toHaveBeenCalledWith("Bearer rotated-jwt") + + const persisted = updateAccountMock.mock.calls.find( + ([, update]) => (update as { sub2apiAuth?: unknown }).sub2apiAuth, + ) + expect(persisted).toBeDefined() + expect(persisted?.[0]).toBe("account-1") + expect(persisted?.[1]).toMatchObject({ + account_info: { access_token: "rotated-jwt" }, + sub2apiAuth: { refreshToken: "rotated-refresh" }, + }) + }) +}) diff --git a/tests/services/autoCheckin/scheduler.test.ts b/tests/services/autoCheckin/scheduler.test.ts index 8d23df9e3b..dfaa9139b9 100644 --- a/tests/services/autoCheckin/scheduler.test.ts +++ b/tests/services/autoCheckin/scheduler.test.ts @@ -500,6 +500,7 @@ describe("autoCheckinScheduler.scheduleNextRun", () => { retry_max_attempts: 3, window_length_minutes: 270, deterministic_time_minutes: 570, + sub2api_enabled: false, }) expect(JSON.stringify(snapshotCall?.[1])).not.toContain("08:15") expect(JSON.stringify(snapshotCall?.[1])).not.toContain("12:45") diff --git a/tests/services/checkin/sub2apiCheckinPreference.test.ts b/tests/services/checkin/sub2apiCheckinPreference.test.ts new file mode 100644 index 0000000000..c0da4bfb29 --- /dev/null +++ b/tests/services/checkin/sub2apiCheckinPreference.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { isSub2ApiCheckinEnabled } from "~/services/checkin/sub2apiCheckinPreference" + +const { getPreferencesMock } = vi.hoisted(() => ({ + getPreferencesMock: vi.fn(), +})) + +vi.mock("~/services/preferences/userPreferences", () => ({ + userPreferences: { + getPreferences: (...args: unknown[]) => getPreferencesMock(...args), + }, + DEFAULT_PREFERENCES: { + autoCheckin: { + sub2apiEnabled: false, + }, + }, +})) + +describe("isSub2ApiCheckinEnabled", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("resolves true when the stored preference is enabled", async () => { + getPreferencesMock.mockResolvedValue({ + autoCheckin: { sub2apiEnabled: true }, + }) + + await expect(isSub2ApiCheckinEnabled()).resolves.toBe(true) + }) + + it("resolves false when the stored preference is disabled", async () => { + getPreferencesMock.mockResolvedValue({ + autoCheckin: { sub2apiEnabled: false }, + }) + + await expect(isSub2ApiCheckinEnabled()).resolves.toBe(false) + }) + + it("falls back to the default preference when autoCheckin is missing", async () => { + getPreferencesMock.mockResolvedValue({}) + + await expect(isSub2ApiCheckinEnabled()).resolves.toBe(false) + }) + + it("treats a storage read failure as disabled so no probe is ever sent", async () => { + getPreferencesMock.mockRejectedValue(new Error("storage unavailable")) + + await expect(isSub2ApiCheckinEnabled()).resolves.toBe(false) + }) +}) diff --git a/tests/services/configMigration/preferences/preferencesMigration.test.ts b/tests/services/configMigration/preferences/preferencesMigration.test.ts index be9f8aa131..bae01c85a2 100644 --- a/tests/services/configMigration/preferences/preferencesMigration.test.ts +++ b/tests/services/configMigration/preferences/preferencesMigration.test.ts @@ -96,6 +96,7 @@ function createV0Preferences( globalEnabled: false, pretriggerDailyOnUiOpen: true, notifyUiOnCompletion: true, + sub2apiEnabled: false, windowStart: "09:00", windowEnd: "18:00", scheduleMode: "random", @@ -744,6 +745,7 @@ describe("preferencesMigration", () => { globalEnabled: false, pretriggerDailyOnUiOpen: true, notifyUiOnCompletion: true, + sub2apiEnabled: false, windowStart: "09:00", windowEnd: "18:00", scheduleMode: "random", diff --git a/tests/services/productAnalytics/autoCheckin.test.ts b/tests/services/productAnalytics/autoCheckin.test.ts index 1e4acb2ba3..6d74e30f20 100644 --- a/tests/services/productAnalytics/autoCheckin.test.ts +++ b/tests/services/productAnalytics/autoCheckin.test.ts @@ -42,6 +42,7 @@ describe("auto-checkin product analytics", () => { globalEnabled: true, pretriggerDailyOnUiOpen: false, notifyUiOnCompletion: true, + sub2apiEnabled: true, windowStart: "08:15", windowEnd: "12:45", scheduleMode: AUTO_CHECKIN_SCHEDULE_MODE.DETERMINISTIC, @@ -59,6 +60,7 @@ describe("auto-checkin product analytics", () => { setting_id: PRODUCT_ANALYTICS_SETTING_IDS.AutoCheckinConfigSnapshot, entrypoint: PRODUCT_ANALYTICS_ENTRYPOINTS.Background, global_enabled: true, + sub2api_enabled: true, ui_pretrigger_enabled: false, notify_completion_enabled: true, retry_enabled: true, @@ -79,6 +81,7 @@ describe("auto-checkin product analytics", () => { globalEnabled: false, pretriggerDailyOnUiOpen: true, notifyUiOnCompletion: false, + sub2apiEnabled: false, windowStart: "20:00", windowEnd: "01:00", scheduleMode: AUTO_CHECKIN_SCHEDULE_MODE.RANDOM, @@ -110,6 +113,7 @@ describe("auto-checkin product analytics", () => { globalEnabled: true, pretriggerDailyOnUiOpen: false, notifyUiOnCompletion: true, + sub2apiEnabled: false, windowStart: "08:30:59", windowEnd: "12:00", scheduleMode: AUTO_CHECKIN_SCHEDULE_MODE.DETERMINISTIC, @@ -133,6 +137,7 @@ describe("auto-checkin product analytics", () => { globalEnabled: false, pretriggerDailyOnUiOpen: true, notifyUiOnCompletion: false, + sub2apiEnabled: false, windowStart: "24:00", windowEnd: "12:30", scheduleMode: AUTO_CHECKIN_SCHEDULE_MODE.DETERMINISTIC, @@ -163,6 +168,7 @@ describe("auto-checkin product analytics", () => { globalEnabled: true, pretriggerDailyOnUiOpen: true, notifyUiOnCompletion: true, + sub2apiEnabled: false, windowStart: "23:45", windowEnd: "00:15", scheduleMode: AUTO_CHECKIN_SCHEDULE_MODE.DETERMINISTIC, diff --git a/tests/services/productAnalytics/settings.test.ts b/tests/services/productAnalytics/settings.test.ts index 9b277e447d..e81e97f8e6 100644 --- a/tests/services/productAnalytics/settings.test.ts +++ b/tests/services/productAnalytics/settings.test.ts @@ -152,6 +152,7 @@ describe("settings product analytics snapshots", () => { globalEnabled: true, pretriggerDailyOnUiOpen: true, notifyUiOnCompletion: false, + sub2apiEnabled: false, windowStart: "08:00", windowEnd: "12:00", scheduleMode: AUTO_CHECKIN_SCHEDULE_MODE.DETERMINISTIC, @@ -360,6 +361,7 @@ describe("settings product analytics snapshots", () => { setting_id: PRODUCT_ANALYTICS_SETTING_IDS.AutoCheckinConfigSnapshot, entrypoint: PRODUCT_ANALYTICS_ENTRYPOINTS.Options, global_enabled: true, + sub2api_enabled: false, ui_pretrigger_enabled: true, notify_completion_enabled: false, retry_enabled: true, @@ -505,6 +507,7 @@ describe("settings product analytics snapshots", () => { webdav_configured: true, webdav_auto_sync_enabled: true, auto_checkin_global_enabled: false, + auto_checkin_sub2api_enabled: false, temp_window_fallback_automatic_bypass_enabled: true, }), )