test(desktop): unit tests for bannerFor + suppress redundant banner in lockout

Phase 4 polish: GraceBanner was rendering for state=locked/tampered
even though LockoutOverlay covers the screen for those states. The
banner leaked through the overlay's 96%-opaque background as a
faint red strip - confusing.

- GraceBanner.tsx: bannerFor() returns null for locked/tampered
  (LockoutOverlay handles the messaging). Also exported bannerFor
  so the new unit tests can exercise the pure decision logic.
- __tests__/GraceBanner.test.ts (new): 13 vitest cases covering
  pre-expiry amber/red thresholds (trial_ends_at vs subscription_period_end),
  fallback to claims.exp for legacy JWTs, soft/hard grace messaging,
  dismissibility rules.
- vitest.config.ts: include also matches src/**/__tests__/ so the
  desktop tests are discovered without moving them.

Runtime ~600ms vs 5-25 min for a full installer rebuild - lets
future iterations on the banner state machine skip the build cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-25 12:05:39 -03:00
parent 6f63fceb20
commit 161335a5cf
3 changed files with 269 additions and 19 deletions

View File

@ -61,16 +61,32 @@ type BannerInfo = {
dismissible: boolean;
};
function bannerFor(status: LicenseStatus, now: number): BannerInfo | null {
// Exported for unit testing in __tests__/GraceBanner.test.ts.
// Pure function: deterministic output for given (status, now) - easy
// to assert against without rendering React.
export function bannerFor(status: LicenseStatus, now: number): BannerInfo | null {
if (status.state === 'active') {
const secondsUntilExp = status.claims.exp - Math.floor(now / 1000);
if (secondsUntilExp <= 0) return null; // shell hasn't updated state yet, ignore
// Pre-expiry warnings target the REAL expiry of the
// entitlement (trial_ends_at for trials, subscription_period_end
// for paid), NOT claims.exp - that one is the JWT cache window
// (typically 7d) which gets refreshed every 6h by the checkin
// loop, so it would never trigger the "5d / 24h" thresholds
// under normal online use. Fall back to claims.exp only when
// neither real-expiry field is present.
const isTrial = status.claims.plan === 'trial';
const realExp = isTrial
? status.claims.trial_ends_at
: status.claims.subscription_period_end;
const effectiveExp = realExp ?? status.claims.exp;
const secondsUntilExp = effectiveExp - Math.floor(now / 1000);
if (secondsUntilExp <= 0) return null; // shell hasn't transitioned state yet, ignore
const hoursUntilExp = secondsUntilExp / 3600;
const subject = isTrial ? 'free trial' : 'Velxio Pro subscription';
if (hoursUntilExp <= 24) {
const h = Math.max(1, Math.round(hoursUntilExp));
return {
tone: 'red',
message: `Your Velxio Pro subscription expires in ${h}h. Renew now to avoid interruption.`,
message: `Your ${subject} expires in ${h}h. ${isTrial ? 'Upgrade' : 'Renew'} now to avoid interruption.`,
dismissible: false,
};
}
@ -78,7 +94,7 @@ function bannerFor(status: LicenseStatus, now: number): BannerInfo | null {
const d = Math.max(1, Math.round(hoursUntilExp / 24));
return {
tone: 'amber',
message: `Your Velxio Pro subscription expires in ${d} day${d === 1 ? '' : 's'}. Renew to avoid interruption.`,
message: `Your ${subject} expires in ${d} day${d === 1 ? '' : 's'}. ${isTrial ? 'Upgrade' : 'Renew'} to avoid interruption.`,
dismissible: true,
};
}
@ -100,19 +116,14 @@ function bannerFor(status: LicenseStatus, now: number): BannerInfo | null {
dismissible: false,
};
}
if (status.state === 'locked') {
return {
tone: 'red',
message: 'Your Velxio Desktop license has expired offline. Reconnect to continue using the editor.',
dismissible: false,
};
}
if (status.state === 'tampered') {
return {
tone: 'red',
message: 'Velxio could not verify the stored license. Sign out and sign in again.',
dismissible: false,
};
// Locked + Tampered are covered by the full-screen LockoutOverlay
// (z-index 10001) that index.ts mounts on `velxio://license-required`.
// Returning a banner here would render behind the overlay and bleed
// through its 96%-opaque background - users see a confusing red
// strip behind the modal. The overlay's own copy already explains
// the state; banner is redundant.
if (status.state === 'locked' || status.state === 'tampered') {
return null;
}
return null;
}

View File

@ -0,0 +1,239 @@
/**
* Vitest for GraceBanner.bannerFor pure function that picks the
* right banner tone based on license status + current time.
*
* Run from velxio/frontend:
* npx vitest run src/desktop/__tests__/GraceBanner.test.ts
*
* Or via the umbrella script:
* E:\Hardware\velxio-prod\pro\desktop\testeo\run-tests.bat
*
* These tests are pure logic (no DOM, no React) so they finish in
* milliseconds. Render-level assertions for the actual component
* are covered by manual smoke-tests after a real install.
*/
import { describe, it, expect } from 'vitest';
import { bannerFor } from '../GraceBanner';
type Claims = {
sub: string;
plan: string;
ent: Record<string, boolean>;
iat: number;
exp: number;
trial_ends_at?: number | null;
subscription_period_end?: number | null;
hard_grace_hours?: number;
};
function claimsTrial(overrides: Partial<Claims> = {}): Claims {
return {
sub: 'vlx_trial_test',
plan: 'trial',
ent: { desktop: true },
iat: 0,
exp: 0,
trial_ends_at: null,
subscription_period_end: null,
hard_grace_hours: 24,
...overrides,
};
}
const DAY = 86400 * 1000;
const HOUR = 3600 * 1000;
describe('bannerFor — active state pre-expiry', () => {
const nowMs = Date.now();
it('returns null when trial expires in >5 days', () => {
const claims = claimsTrial({
exp: Math.floor((nowMs + 30 * DAY) / 1000),
trial_ends_at: Math.floor((nowMs + 10 * DAY) / 1000),
});
const banner = bannerFor({ state: 'active', claims }, nowMs);
expect(banner).toBeNull();
});
it('returns amber when trial expires in 5 days', () => {
const claims = claimsTrial({
exp: Math.floor((nowMs + 30 * DAY) / 1000),
trial_ends_at: Math.floor((nowMs + 4 * DAY) / 1000),
});
const banner = bannerFor({ state: 'active', claims }, nowMs);
expect(banner).not.toBeNull();
expect(banner!.tone).toBe('amber');
expect(banner!.dismissible).toBe(true);
expect(banner!.message).toMatch(/free trial/);
expect(banner!.message).toMatch(/4 day/);
});
it('returns red when trial expires in 23 hours', () => {
const claims = claimsTrial({
exp: Math.floor((nowMs + 30 * DAY) / 1000),
trial_ends_at: Math.floor((nowMs + 23 * HOUR) / 1000),
});
const banner = bannerFor({ state: 'active', claims }, nowMs);
expect(banner).not.toBeNull();
expect(banner!.tone).toBe('red');
expect(banner!.dismissible).toBe(false);
expect(banner!.message).toMatch(/23h/);
});
it('falls back to claims.exp when trial_ends_at is missing', () => {
// Pre-v0.3.0 JWTs didn't carry trial_ends_at separately. Make
// sure we still produce a banner so cached old JWTs don't go
// silent during the upgrade window.
const claims = claimsTrial({
exp: Math.floor((nowMs + 3 * DAY) / 1000),
trial_ends_at: null,
});
const banner = bannerFor({ state: 'active', claims }, nowMs);
expect(banner).not.toBeNull();
expect(banner!.tone).toBe('amber');
});
it('uses subscription_period_end for paid plans', () => {
const claims = claimsTrial({
plan: 'pro',
exp: Math.floor((nowMs + 30 * DAY) / 1000),
trial_ends_at: null,
subscription_period_end: Math.floor((nowMs + 3 * DAY) / 1000),
});
const banner = bannerFor({ state: 'active', claims }, nowMs);
expect(banner).not.toBeNull();
expect(banner!.tone).toBe('amber');
expect(banner!.message).toMatch(/Velxio Pro subscription/);
expect(banner!.message).not.toMatch(/free trial/);
});
it('returns null when secondsUntilExp <= 0 (state should be soft_grace already)', () => {
const claims = claimsTrial({
exp: Math.floor((nowMs - 60 * 1000) / 1000),
trial_ends_at: Math.floor((nowMs - 60 * 1000) / 1000),
});
const banner = bannerFor({ state: 'active', claims }, nowMs);
expect(banner).toBeNull();
});
});
describe('bannerFor — post-expiry grace states', () => {
const nowMs = Date.now();
const expiredClaims = claimsTrial({
exp: Math.floor((nowMs - 5 * DAY) / 1000),
trial_ends_at: Math.floor((nowMs - 5 * DAY) / 1000),
});
it('soft_grace returns amber with offline-grace messaging', () => {
const banner = bannerFor(
{ state: 'soft_grace', claims: expiredClaims, days_remaining: 2 },
nowMs,
);
expect(banner).not.toBeNull();
expect(banner!.tone).toBe('amber');
expect(banner!.dismissible).toBe(false);
expect(banner!.message).toMatch(/offline grace/);
expect(banner!.message).toMatch(/2 day/);
});
it('hard_grace returns red and disables operations', () => {
const banner = bannerFor(
{ state: 'hard_grace', claims: expiredClaims, hours_remaining: 12 },
nowMs,
);
expect(banner).not.toBeNull();
expect(banner!.tone).toBe('red');
expect(banner!.dismissible).toBe(false);
expect(banner!.message).toMatch(/Compile and Save are temporarily disabled/);
});
it('locked returns null (LockoutOverlay covers the UI)', () => {
const banner = bannerFor(
{ state: 'locked', last_plan: 'trial' },
nowMs,
);
expect(banner).toBeNull();
});
it('tampered returns null (LockoutOverlay covers the UI)', () => {
const banner = bannerFor({ state: 'tampered' }, nowMs);
expect(banner).toBeNull();
});
});
describe('bannerFor — quiet states', () => {
const nowMs = Date.now();
it('unauthenticated returns null (no key = no banner, LockoutOverlay handles UI)', () => {
const banner = bannerFor({ state: 'unauthenticated' }, nowMs);
expect(banner).toBeNull();
});
it('active well-future expiry returns null', () => {
const claims = claimsTrial({
exp: Math.floor((nowMs + 90 * DAY) / 1000),
trial_ends_at: Math.floor((nowMs + 60 * DAY) / 1000),
});
const banner = bannerFor({ state: 'active', claims }, nowMs);
expect(banner).toBeNull();
});
});
describe('bannerFor — dismissibility', () => {
const nowMs = Date.now();
it('only the amber pre-expiry banner is dismissible', () => {
const dismissible = [
bannerFor(
{
state: 'active',
claims: claimsTrial({
exp: Math.floor((nowMs + 30 * DAY) / 1000),
trial_ends_at: Math.floor((nowMs + 4 * DAY) / 1000),
}),
},
nowMs,
),
];
const nonDismissible = [
bannerFor(
{
state: 'active',
claims: claimsTrial({
exp: Math.floor((nowMs + 30 * DAY) / 1000),
trial_ends_at: Math.floor((nowMs + 1 * HOUR) / 1000),
}),
},
nowMs,
),
bannerFor(
{
state: 'soft_grace',
claims: claimsTrial(),
days_remaining: 2,
},
nowMs,
),
bannerFor(
{
state: 'hard_grace',
claims: claimsTrial(),
hours_remaining: 12,
},
nowMs,
),
];
for (const b of dismissible) {
expect(b?.dismissible).toBe(true);
}
for (const b of nonDismissible) {
// null banners (locked/tampered) are filtered out by the
// returned-null short-circuit in GraceBanner itself.
if (b !== null) {
expect(b.dismissible).toBe(false);
}
}
});
});

View File

@ -20,7 +20,7 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/__tests__/**/*.test.ts'],
include: ['src/__tests__/**/*.test.ts', 'src/**/__tests__/**/*.test.ts'],
testTimeout: 30_000,
hookTimeout: 30_000,
// Vitest 4 removed `test.poolOptions` — config moved to top-level