Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/components/layout/UserMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import { useI18n } from 'vue-i18n'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useAccountStore } from '@/store/accountStore'
import { useEntitlementStore } from '@/store/entitlementStore'
import { openLoginUrl, openRegisterUrl } from '@/utils/authService'

const CONSOLE_BASE_URL = 'https://console.geekfun.club'
import { GEEKFUN_BASE_URL as CONSOLE_BASE_URL, openLoginUrl, openRegisterUrl } from '@/utils/authService'

const { t } = useI18n()
const accountStore = useAccountStore()
Expand Down
37 changes: 32 additions & 5 deletions src/store/entitlementStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ const REFRESH_RETRY_DELAYS_MS = [1500, 3000, 6000]

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

// Terminal failure without a cached view — a synthetic answer so the UI
// lands on Unknown (with its retry CTA) instead of an endless Checking…
function unknownView(lastError: string): EntitlementView {
return {
ultimateActive: false,
versionLocked: false,
localUltimate: false,
appReleaseDate: '',
ultimateExpiresAt: null,
versionLockHorizon: null,
cancelScheduledAt: null,
cached: true,
fetchedAtMs: null,
lastError,
}
}

export const useEntitlementStore = defineStore('entitlement', {
state: (): { view: EntitlementView | null } => ({
view: null,
Expand All @@ -20,8 +37,11 @@ export const useEntitlementStore = defineStore('entitlement', {
// 'community' is only claimed when the server answered; a failed or
// missing check must never masquerade as a confirmed plan.
planState: (state): PlanState => {
const accountStore = useAccountStore()
if (state.view?.localUltimate)
return 'ultimate' as PlanState
if (!accountStore.isLoggedIn)
return 'unknown' as PlanState
// no server answer yet — the first refresh is still in flight
if (state.view === null)
return 'checking' as PlanState
Expand Down Expand Up @@ -76,15 +96,22 @@ export const useEntitlementStore = defineStore('entitlement', {
}
catch (e) {
if (isSessionRejected(e)) {
// dead lease — drop it so the next login starts clean
// dead lease — drop it so the next login starts clean, and land
// on the session-expired state instead of an endless Checking…
accountStore.setRefreshToken('')
this.view = null
this.view = unknownView('session expired')
return
}
if (attempt >= REFRESH_RETRY_DELAYS_MS.length) {
if (!isEntitlementError(e))
throw e
this.view = null
// terminal: a cached view degrades to itself (offline contract);
// without one, surface Unknown rather than an endless Checking…
if (!this.view) {
this.view = unknownView(
isEntitlementError(e)
? 'entitlement check failed'
: typeof e === 'string' ? e : String(e),
)
}
return
}
await sleep(REFRESH_RETRY_DELAYS_MS[attempt])
Expand Down
2 changes: 1 addition & 1 deletion src/utils/authService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { openUrl } from '@tauri-apps/plugin-opener'

const GEEKFUN_BASE_URL = 'https://console.geekfun.club'
export const GEEKFUN_BASE_URL = 'https://console.geekfun.club'

export async function openLoginUrl(): Promise<void> {
const loginUrl = `${GEEKFUN_BASE_URL}/login?source=sqlkit`
Expand Down
37 changes: 28 additions & 9 deletions tests/store/entitlementStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe('entitlementStore', () => {

expect(mockInvoke).toHaveBeenCalledTimes(1)
expect(mockSetRefreshToken).toHaveBeenCalledWith('')
expect(store.view).toBeNull()
expect(store.sessionExpired).toBe(true)
})

it('retries a transient failure and succeeds', async () => {
Expand All @@ -153,31 +153,50 @@ describe('entitlementStore', () => {
expect(store.planState).toBe('ultimate')
})

it('rethrows a persistent non-entitlement failure with the view left empty', async () => {
it('lands on Unknown after persistent non-entitlement failures', async () => {
jest.useFakeTimers()
mockInvoke.mockRejectedValue('network error: timeout')
const store = useEntitlementStore()

const pending = store.refreshEntitlement(false)
// attach the rejection handler before the timers fire the retries
const assertion = expect(pending).rejects.toBe('network error: timeout')
await jest.runAllTimersAsync()
await assertion
await pending

expect(mockInvoke).toHaveBeenCalledTimes(4) // 1 attempt + 3 retries
expect(store.view).toBeNull()
expect(store.view).not.toBeNull()
expect(store.planState).toBe('unknown')
expect(store.hasEntitlementError).toBe(true)
expect(store.sessionExpired).toBe(false)
})

it('resolves with an empty view for a definitive entitlement answer', async () => {
it('keeps a hydrated cached view when the refresh degrades', async () => {
mockInvoke.mockResolvedValueOnce(view({ localUltimate: true, fetchedAtMs: 42 }))
const store = useEntitlementStore()
await store.hydrate()

jest.useFakeTimers()
mockInvoke.mockRejectedValue('network error: timeout')
const pending = store.refreshEntitlement(false)
await jest.runAllTimersAsync()
await pending

// offline contract: the last success stays on screen
expect(store.view?.fetchedAtMs).toBe(42)
expect(store.isLocalUltimate).toBe(true)
expect(store.hasEntitlementError).toBe(false)
})

it('resolves with Unknown for a definitive entitlement answer', async () => {
jest.useFakeTimers()
mockInvoke.mockRejectedValue(rejected(ENTITLEMENT_ERROR_TYPE, 'requires Ultimate'))
const store = useEntitlementStore()

const pending = store.refreshEntitlement(false)
await jest.runAllTimersAsync()
await pending

await expect(pending).resolves.toBeUndefined()
expect(store.view).toBeNull()
expect(store.planState).toBe('unknown')
expect(store.sessionExpired).toBe(false)
})
})

Expand Down
Loading