DEV Community

TheophilusHawkins9265
TheophilusHawkins9265

Posted on

Testing Realtime Token Expiry in a Stock Watchlist (Without Flaky Timing)

A token-expiry test fails the moment wall-clock timing becomes part of its assertion. Short answer: model authentication and connectivity as separate state machines, inject a clock, and accept events only from the current connection generation. Then a stock trading watchlist can prove what the user sees before, during, and after expiry without sleeping for a real token lifetime.

The operational target is narrow: quotes may pause while credentials refresh, but a stale disconnect must never erase a newer subscription or claim that an active symbol is offline. This is the same invariant that matters in a gaming chat room after a reconnect: presence belongs to the current session, not whichever socket happened to report last.

I've been paged by missed jobs and duplicate deliveries. That history creates a reflex here: every retry needs an identity, every side effect needs an idempotency rule, and every late event should be assumed possible. A watchlist refresh is quick in a happy-path demo. The incident-shaped question is what happens when expiry, reconnect, and an old callback land in the least convenient order.

How should you test realtime token expiry UX in a stock trading watchlist?

Start by writing the observable contract rather than scripting a browser around a countdown. The contract has four states: connected, refreshing, reconnecting, and degraded. It also has three user-visible facts: whether displayed prices may still be current, whether a refresh is underway, and whether the application has restored every requested symbol. Authentication state and transport state can overlap, so one boolean named connected can't describe the truth.

The useful assertions are about transitions:

Trigger Watchlist behavior Assertion
Expiry enters the refresh window Keep the last accepted prices visible and mark them as updating No row disappears and only one refresh starts
New credentials arrive Open a new connection and replay the desired symbols Each desired symbol is subscribed once for the new generation
An old connection closes late Ignore its callback Current rows and connection state do not change
Refresh cannot complete by the deadline Show prices as stale and offer an explicit retry Staleness is visible; old data is not presented as live

This separates expiry UX from network theater. The test doesn't need to know whether a transport closes in 8 ms or 80 ms. It advances logical time to a boundary, injects the event that the boundary permits, and checks the projection rendered for the user.

One detail matters more than it first appears: keep a desired-subscription set outside the connection object. AAPL, MSFT, and NVDA are user intent; socket subscriptions are an attempt to realize that intent. A reconnect creates a fresh attempt from the same set. If those concepts share storage, a late cleanup from generation 7 can delete subscriptions already restored by generation 8.

That is the failure mode to make boring.

Make time and connection identity explicit

The smallest useful design has an injected clock and a monotonically increasing generation. Production code can read a real clock at the boundary. Tests control it. The generation travels with every asynchronous event, and the reducer discards any event whose generation differs from the active one.

Here is a compact Go model. It isn't a transport implementation; it is the policy layer that a WebSocket, data channel, or polling adapter can drive.

package watchlist

import "time"

type Phase string

const (
    Connected    Phase = "connected"
    Refreshing   Phase = "refreshing"
    Reconnecting Phase = "reconnecting"
    Degraded     Phase = "degraded"
)

type Clock interface {
    Now() time.Time
}

type State struct {
    Phase       Phase
    Generation uint64
    ExpiresAt  time.Time
    Symbols    map[string]struct{}
    LastPrices map[string]int64 // Integer minor units avoid float comparisons.
}

func (s State) NeedsRefresh(now time.Time, lead time.Duration) bool {
    return !now.Before(s.ExpiresAt.Add(-lead))
}

func (s *State) BeginRefresh(now time.Time, lead time.Duration) bool {
    if s.Phase != Connected || !s.NeedsRefresh(now, lead) {
        return false
    }
    s.Phase = Refreshing
    return true
}

func (s *State) CredentialsReady(expiresAt time.Time) uint64 {
    s.Generation++
    s.ExpiresAt = expiresAt
    s.Phase = Reconnecting
    return s.Generation
}

func (s *State) ConnectionReady(generation uint64) []string {
    if generation != s.Generation {
        return nil
    }
    s.Phase = Connected
    symbols := make([]string, 0, len(s.Symbols))
    for symbol := range s.Symbols {
        symbols = append(symbols, symbol)
    }
    return symbols
}

func (s *State) ConnectionClosed(generation uint64) {
    if generation != s.Generation {
        return
    }
    s.Phase = Reconnecting
}
Enter fullscreen mode Exit fullscreen mode

The integer price is deliberate. A timing test shouldn't fail because it also inherited floating-point ambiguity. In production, the symbol replay should likewise be idempotent at the adapter boundary: the current generation and symbol form the operation identity, so repeated delivery doesn't create repeated listeners.

There is a catch. A generation counter orders events observed by one process; it does not prove global ordering across browser tabs, regions, or independent services. If the product needs cross-device presence accuracy, the server needs an authoritative session or lease model, and the client generation remains a local defense. Stick with a simpler refresh-and-reload flow when users don't need continuous updates, because maintaining resumable state adds code, telemetry, and operational paths that a periodic snapshot avoids.

Test the race, not the duration

A deterministic suite should enumerate event orderings around the boundary. Don't set a token to expire in two seconds and hope the test runner gets scheduled on time. Advance a fake clock to one instant before the refresh threshold, assert no action, advance to the threshold, and assert exactly one refresh. Then deliver new-connection and old-close events in both orders.

package watchlist

import (
    "reflect"
    "sort"
    "testing"
    "time"
)

func TestExpiryReconnectIgnoresLateClose(t *testing.T) {
    base := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
    s := State{
        Phase:       Connected,
        Generation: 7,
        ExpiresAt:  base.Add(60 * time.Second),
        Symbols: map[string]struct{}{
            "AAPL": {},
            "MSFT": {},
        },
        LastPrices: map[string]int64{"AAPL": 20315, "MSFT": 41780},
    }

    if s.BeginRefresh(base.Add(49*time.Second), 10*time.Second) {
        t.Fatal("refresh started before the boundary")
    }
    if !s.BeginRefresh(base.Add(50*time.Second), 10*time.Second) {
        t.Fatal("refresh did not start at the boundary")
    }
    if s.BeginRefresh(base.Add(51*time.Second), 10*time.Second) {
        t.Fatal("duplicate refresh started")
    }

    current := s.CredentialsReady(base.Add(10 * time.Minute))
    got := s.ConnectionReady(current)
    sort.Strings(got)
    if want := []string{"AAPL", "MSFT"}; !reflect.DeepEqual(got, want) {
        t.Fatalf("replayed symbols = %v, want %v", got, want)
    }

    s.ConnectionClosed(current - 1) // A delayed callback from generation 7.
    if s.Phase != Connected {
        t.Fatalf("late close changed current phase to %q", s.Phase)
    }
    if len(s.LastPrices) != 2 {
        t.Fatal("late close removed visible prices")
    }
}
Enter fullscreen mode Exit fullscreen mode

The exact numbers are fixtures, not claims about a provider's token policy. I'm not sure there is one refresh lead time that works for every deployment. Measure credential-issuance latency and reconnect latency in the target environment, choose a margin from that evidence, and keep the tests expressed relative to the configured margin.

The longer incident test should add one more unpleasant ordering: the user removes MSFT while refresh is in flight, generation 8 connects, and generation 7 reports a final quote. The expected projection contains no MSFT row. This catches an easy mistake where replay uses a snapshot captured at refresh start rather than the latest desired set. It also forces a policy decision about buffered quotes: accept them only when both generation and desired membership still match.

Fast tests can cover a matrix of boundary times and event permutations. Keep a much smaller adapter test to verify that the real timer calls the policy and that transport callbacks carry the captured generation. Browser tests should focus on rendered language and controls, not wait for actual expiry. This division makes a failure diagnostic: reducer failures mean policy, adapter failures mean wiring, and end-to-end failures mean projection or integration.

Define what users see when recovery loses the race

Correct internal state is insufficient if the screen implies certainty it doesn't have. During refresh, retain the last accepted quote, display its observation time, and mark the row as updating. After a configured recovery deadline, change the status to stale and expose retry. Do not replace a known last value with zero, and do not animate an old value as though it were a new tick.

Presence accuracy deserves the same treatment in gaming chat. A reconnecting player can remain visible with a transitional state, but an old session's leave event must not mark the current session absent. The authoritative scope decides the mechanism: a local generation is enough to reject stale callbacks in one client; shared presence needs a server-issued session identity or lease. WebRTC defines peer-connection state and events, but application authentication, token refresh, presence meaning, and reconnection policy remain application concerns. Test those policies directly instead of treating transport state as product truth.

Watch four signals in production: refresh attempts, refresh outcomes, time from refresh start to current-generation readiness, and stale-event rejection count. Break them down by client version and connection path. A rising stale-event count with successful recovery suggests event reordering or churn; a rising degraded-state duration points toward credential or reconnect latency. Neither metric should page on a single event. Alert on user impact: watchlists remaining stale beyond the operational objective or sessions failing to restore the desired symbol set.

This approach is not suitable when continuity itself is legally or financially authoritative. A client-side watchlist is a display projection, not an execution ledger. Orders, acknowledgements, and regulated records need server-authoritative sequencing, durable identifiers, and reconciliation against the source of record. Don't stretch a UI generation counter into that job.

The release gate is straightforward: no wall-clock sleeps in policy tests; every callback carries a generation; desired subscriptions outlive connections; stale values are labeled; and the degraded path is exercised on purpose. Ship only when the worst ordering is as well specified as the happy one.

References

Top comments (1)

Collapse
 
officialmailkr profile image
오피셜메일 •

재연결 세대와 사용자가 원하는 구독 집합을 분리한 점이 핵심이네요. 갱신 중 종목을 삭제하는 경우뿐 아니라 삭제 후 같은 종목을 다시 추가하는 순서도 테스트하면, 오래된 해제 콜백이 새 구독까지 지우는 문제를 검증할 수 있겠습니다. 화면에 관측 시각과 stale 표시를 남기는 원칙도 좋습니다.