blob: 9bee747a4705041adafd87e9542eaba94be9b2d6 [file] [edit]
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package bigtable
import (
"context"
"testing"
"time"
btpb "cloud.google.com/go/bigtable/apiv2/bigtablepb"
"cloud.google.com/go/bigtable/internal/session"
btransport "cloud.google.com/go/bigtable/internal/transport"
"go.opentelemetry.io/otel/metric"
"google.golang.org/grpc/metadata"
)
// newBareClientForOpenTests builds a Client with just enough state for
// the Open* factory paths — no gRPC connection, no metrics tracer, no
// pools. Sufficient because Open* only reads project/instance/
// appProfile/featureFlagsMD/diverter and constructs Table + TableShim
// values without dialing.
func newBareClientForOpenTests(t *testing.T, sessionLoad float64) *Client {
t.Helper()
return &Client{
project: "p",
instance: "i",
appProfile: "ap",
featureFlagsMD: metadata.MD{},
diverter: btransport.NewDiverter(sessionLoad),
}
}
// TestOpen_WiresDivertibleWhenDiverterPresent pins Open's session-
// routing contract: when c.diverter is set, the returned *Table has
// divertible populated with a *TableShim, so Apply / ReadRow route
// through the shim. Backward-compatible: the return type stays
// *Table and every non-Apply/ReadRow method is untouched.
func TestOpen_WiresDivertibleWhenDiverterPresent(t *testing.T) {
c := newBareClientForOpenTests(t, 0.0)
tbl := c.Open("mytable")
if tbl == nil {
t.Fatal("Open returned nil")
}
if tbl.c != c {
t.Errorf("Open→Table.c = %p, want %p", tbl.c, c)
}
if tbl.table != "mytable" {
t.Errorf("Open→Table.table = %q, want %q", tbl.table, "mytable")
}
if tbl.authorizedView != "" {
t.Errorf("Open→Table.authorizedView = %q, want empty", tbl.authorizedView)
}
if tbl.materializedView != "" {
t.Errorf("Open→Table.materializedView = %q, want empty", tbl.materializedView)
}
if tbl.divertible == nil {
t.Fatal("Open→Table.divertible = nil, want *TableShim (c.diverter is set)")
}
shim, ok := tbl.divertible.(*TableShim)
if !ok {
t.Fatalf("Open→Table.divertible = %T, want *TableShim", tbl.divertible)
}
if shim.diverter != c.diverter {
t.Errorf("shim.diverter = %p, want client's diverter %p", shim.diverter, c.diverter)
}
}
// TestOpen_NoDivertibleWhenDiverterAbsent pins the zero-cost classic
// path: with c.diverter nil, Open must return a *Table with
// divertible=nil so the Apply / ReadRow gate short-circuits to the
// *Classic helpers without any shim allocation.
func TestOpen_NoDivertibleWhenDiverterAbsent(t *testing.T) {
c := &Client{
project: "p",
instance: "i",
appProfile: "ap",
featureFlagsMD: metadata.MD{},
// diverter deliberately unset.
}
tbl := c.Open("mytable")
if tbl.divertible != nil {
t.Errorf("Open→Table.divertible = %v, want nil (no diverter → no shim)", tbl.divertible)
}
}
// TestOpen_DivertibleShimBreaksRecursionLoop pins the anti-recursion
// invariant that makes Table.Apply / Table.ReadRow gate-then-classic
// pattern safe: the *tableImpl on the shim's classic side wraps a
// SNAPSHOT of the outer Table with divertible EXPLICITLY nil-ed.
//
// Without this break, tableImpl.Apply → Table.Apply would re-enter
// the gate → shim → tableImpl.Apply → ... forever. The tableImpl
// override calling applyClassic/readRowClassic directly is the second
// half of the safety net; this test guards the first half — the
// value-copy + nil field trick.
func TestOpen_DivertibleShimBreaksRecursionLoop(t *testing.T) {
c := newBareClientForOpenTests(t, 0.0)
tbl := c.Open("mytable")
shim, ok := tbl.divertible.(*TableShim)
if !ok {
t.Fatalf("divertible = %T, want *TableShim", tbl.divertible)
}
inner, ok := shim.classic.(*tableImpl)
if !ok {
t.Fatalf("shim.classic = %T, want *tableImpl", shim.classic)
}
if inner.table != "mytable" {
t.Errorf("inner.table = %q, want %q (snapshot must carry the same table id)", inner.table, "mytable")
}
if inner.divertible != nil {
t.Errorf("inner.divertible = %v, want nil (must be cleared to break the recursion loop)", inner.divertible)
}
}
// TestOpen_ApplyRoutesThroughDivertible drives an end-to-end Apply on
// the *Table returned by Open with a wired session backend and
// SessionLoad=1.0. Proves the session path is reached — i.e., the
// divertible gate fires and the shim dispatches to session, not the
// classic MutateRow (which would panic here because the client has
// no gRPC connection).
func TestOpen_ApplyRoutesThroughDivertible(t *testing.T) {
fsc := newFakeSessionClient()
c := newSessionWiredClient(t, fsc)
c.diverter.SetSessionLoad(1.0)
tbl := c.Open("mytable")
if tbl.divertible == nil {
t.Fatal("divertible = nil, want shim (diverter + sessionImpl wired)")
}
mut := NewMutation()
mut.Set("cf", "q", 1_000_000, []byte("v"))
if err := tbl.Apply(context.Background(), "row-1", mut); err != nil {
t.Fatalf("Apply: %v", err)
}
if got := len(fsc.openTableCalls); got != 1 || fsc.openTableCalls[0] != "mytable" {
t.Errorf("fake session OpenTable calls = %v, want [mytable]", fsc.openTableCalls)
}
}
// TestOpen_ReadRowRoutesThroughDivertible mirrors the Apply test for
// the read side. Same setup, same proof — the divertible gate on
// Table.ReadRow dispatches to the shim's session path.
func TestOpen_ReadRowRoutesThroughDivertible(t *testing.T) {
fsc := newFakeSessionClient()
c := newSessionWiredClient(t, fsc)
c.diverter.SetSessionLoad(1.0)
tbl := c.Open("mytable")
if _, err := tbl.ReadRow(context.Background(), "row-1"); err != nil {
t.Fatalf("ReadRow: %v", err)
}
if got := len(fsc.openTableCalls); got != 1 || fsc.openTableCalls[0] != "mytable" {
t.Errorf("fake session OpenTable calls = %v, want [mytable]", fsc.openTableCalls)
}
}
// TestTableImpl_BypassesDivertibleGate pins the second half of the
// anti-recursion safety net: even if a tableImpl is somehow given a
// Table with divertible set (defensive — buildDivertible always nil-s
// it), the tableImpl.Apply / tableImpl.ReadRow overrides must call
// the *Classic helpers directly and NOT re-enter the gate.
//
// Uses a spy divertible whose methods panic — if the gate fires, the
// test panics; if the bypass works, the test reaches classic and
// fails predictably (no gRPC conn → panic in unrelated code); we
// catch that with recover to distinguish the two failure modes.
func TestTableImpl_BypassesDivertibleGate(t *testing.T) {
spy := &panickingTableAPI{}
ti := &tableImpl{Table: Table{
c: newBareClientForOpenTests(t, 0.0),
table: "mytable",
divertible: spy, // if the gate fires, spy panics with a distinctive message
}}
// Apply: catch any panic and check it's NOT from the spy.
func() {
defer func() {
if r := recover(); r != nil {
if _, ok := r.(spyPanic); ok {
t.Errorf("tableImpl.Apply reached the divertible spy — gate must be bypassed")
}
// Classic body panicking on missing gRPC conn is expected here.
}
}()
_ = ti.Apply(context.Background(), "row-1", NewMutation())
}()
// ReadRow: same shape.
func() {
defer func() {
if r := recover(); r != nil {
if _, ok := r.(spyPanic); ok {
t.Errorf("tableImpl.ReadRow reached the divertible spy — gate must be bypassed")
}
}
}()
_, _ = ti.ReadRow(context.Background(), "row-1")
}()
}
type spyPanic struct{}
type panickingTableAPI struct{}
func (p *panickingTableAPI) ReadRow(context.Context, string, ...ReadOption) (Row, error) {
panic(spyPanic{})
}
func (p *panickingTableAPI) Apply(context.Context, string, *Mutation, ...ApplyOption) error {
panic(spyPanic{})
}
func (p *panickingTableAPI) ReadRows(context.Context, RowSet, func(Row) bool, ...ReadOption) error {
panic(spyPanic{})
}
func (p *panickingTableAPI) SampleRowKeys(context.Context) ([]string, error) { panic(spyPanic{}) }
func (p *panickingTableAPI) ApplyBulk(context.Context, []string, []*Mutation, ...ApplyOption) ([]error, error) {
panic(spyPanic{})
}
func (p *panickingTableAPI) ApplyReadModifyWrite(context.Context, string, *ReadModifyWrite) (Row, error) {
panic(spyPanic{})
}
// TestOpen_ClassicOnlyMethodsSkipDivertibleGate pins the invariant
// that Table methods with no session equivalent (ReadRows, ApplyBulk,
// SampleRowKeys, ApplyReadModifyWrite) MUST NOT gate on t.divertible.
// They run the classic body directly — TableShim's own delegation
// would just be pure indirection since the shim also always delegates
// them to classic (see TableShim.ReadRows / ApplyBulk / etc).
//
// Fixture matches a realistic session-wired client (session backend +
// diverter with SessionLoad=1.0), then swaps divertible for the
// panickingTableAPI spy so we can distinguish gate-dispatch (spy
// panic) from classic-body execution (nil-conn panic in the classic
// body). Under the natural configuration where a stray gate would
// actually route to session, this is where the accident would show.
func TestOpen_ClassicOnlyMethodsSkipDivertibleGate(t *testing.T) {
fsc := newFakeSessionClient()
c := newSessionWiredClient(t, fsc)
c.diverter.SetSessionLoad(1.0)
newTable := func() *Table {
return &Table{
c: c,
table: "mytable",
divertible: &panickingTableAPI{},
}
}
assertNotSpyPanic := func(t *testing.T, method string) {
t.Helper()
if r := recover(); r != nil {
if _, ok := r.(spyPanic); ok {
t.Errorf("Table.%s reached divertible spy — this method must NOT gate on divertible", method)
}
// Any other panic (nil-conn from classic body) is expected.
}
}
t.Run("ReadRows", func(t *testing.T) {
defer assertNotSpyPanic(t, "ReadRows")
_ = newTable().ReadRows(context.Background(), InfiniteRange(""), func(Row) bool { return true })
})
t.Run("ApplyBulk", func(t *testing.T) {
defer assertNotSpyPanic(t, "ApplyBulk")
_, _ = newTable().ApplyBulk(context.Background(), []string{"r"}, []*Mutation{NewMutation()})
})
t.Run("SampleRowKeys", func(t *testing.T) {
defer assertNotSpyPanic(t, "SampleRowKeys")
_, _ = newTable().SampleRowKeys(context.Background())
})
t.Run("ApplyReadModifyWrite", func(t *testing.T) {
defer assertNotSpyPanic(t, "ApplyReadModifyWrite")
_, _ = newTable().ApplyReadModifyWrite(context.Background(), "r", &ReadModifyWrite{})
})
}
// TestOpenTable_ProducesNilSessionShim pins the shim shape returned by
// OpenTable: a *TableShim whose classic side is a *tableImpl and whose
// session side is nil (session data path isn't wired here). The
// client's Diverter is passed through so a future ratio bump takes
// effect without re-opening. The already-existing
// TestTableShim_NilSession_AllMethodsFallBackToClassic covers the
// behavioral consequence — with session == nil, every routing decision
// falls through to classic.
func TestOpenTable_ProducesNilSessionShim(t *testing.T) {
c := newBareClientForOpenTests(t, 1.0) // SessionLoad=1.0 to prove the shim still picks classic when session is nil.
got := c.OpenTable("mytable")
shim, ok := got.(*TableShim)
if !ok {
t.Fatalf("OpenTable returned %T, want *TableShim", got)
}
if shim.session != nil {
t.Errorf("OpenTable→TableShim.session = %v, want nil (session data path not wired)", shim.session)
}
if shim.diverter != c.diverter {
t.Errorf("OpenTable→TableShim.diverter = %p, want client's diverter %p", shim.diverter, c.diverter)
}
inner, ok := shim.classic.(*tableImpl)
if !ok {
t.Fatalf("OpenTable→TableShim.classic = %T, want *tableImpl", shim.classic)
}
if inner.table != "mytable" {
t.Errorf("classic inner Table.table = %q, want %q", inner.table, "mytable")
}
if inner.authorizedView != "" {
t.Errorf("classic inner Table.authorizedView = %q, want empty", inner.authorizedView)
}
if inner.materializedView != "" {
t.Errorf("classic inner Table.materializedView = %q, want empty", inner.materializedView)
}
// useSession() must be false because session is nil, even with
// SessionLoad=1.0 on the diverter — proves the nil-session
// short-circuit runs before the diverter is consulted.
if shim.useSession() {
t.Errorf("useSession() = true, want false (session is nil so we must not consult the diverter)")
}
}
// TestOpenAuthorizedView_ProducesNilSessionShim — same contract as
// OpenTable, plus authorizedView is threaded through to the inner
// Table.
func TestOpenAuthorizedView_ProducesNilSessionShim(t *testing.T) {
c := newBareClientForOpenTests(t, 0.0)
got := c.OpenAuthorizedView("mytable", "myview")
shim, ok := got.(*TableShim)
if !ok {
t.Fatalf("OpenAuthorizedView returned %T, want *TableShim", got)
}
if shim.session != nil {
t.Errorf("session = %v, want nil", shim.session)
}
if shim.diverter != c.diverter {
t.Errorf("diverter = %p, want client's diverter %p", shim.diverter, c.diverter)
}
inner, ok := shim.classic.(*tableImpl)
if !ok {
t.Fatalf("classic = %T, want *tableImpl", shim.classic)
}
if inner.table != "mytable" {
t.Errorf("classic table = %q, want %q", inner.table, "mytable")
}
if inner.authorizedView != "myview" {
t.Errorf("classic authorizedView = %q, want %q", inner.authorizedView, "myview")
}
}
// TestOpenMaterializedView_ProducesNilSessionShim — same contract as
// OpenTable, plus materializedView is threaded through to the inner
// Table (and table is empty since MVs are addressed by view name only).
func TestOpenMaterializedView_ProducesNilSessionShim(t *testing.T) {
c := newBareClientForOpenTests(t, 0.0)
got := c.OpenMaterializedView("myview")
shim, ok := got.(*TableShim)
if !ok {
t.Fatalf("OpenMaterializedView returned %T, want *TableShim", got)
}
if shim.session != nil {
t.Errorf("session = %v, want nil", shim.session)
}
if shim.diverter != c.diverter {
t.Errorf("diverter = %p, want client's diverter %p", shim.diverter, c.diverter)
}
inner, ok := shim.classic.(*tableImpl)
if !ok {
t.Fatalf("classic = %T, want *tableImpl", shim.classic)
}
if inner.materializedView != "myview" {
t.Errorf("classic materializedView = %q, want %q", inner.materializedView, "myview")
}
if inner.table != "" {
t.Errorf("classic table = %q, want empty (MV addressed by view name only)", inner.table)
}
}
// TestOpenFactories_ShareOneClientDiverter pins that every Open*
// factory returns a shim referencing the SAME *Diverter — so a future
// SetSessionLoad call from a ConfigurationManager updates every open
// resource on the client at once, no per-resource iteration.
func TestOpenFactories_ShareOneClientDiverter(t *testing.T) {
c := newBareClientForOpenTests(t, 0.0)
tbl := c.OpenTable("t").(*TableShim)
av := c.OpenAuthorizedView("t", "v").(*TableShim)
mv := c.OpenMaterializedView("mv").(*TableShim)
if tbl.diverter != c.diverter || av.diverter != c.diverter || mv.diverter != c.diverter {
t.Errorf("diverters diverge: tbl=%p av=%p mv=%p client=%p",
tbl.diverter, av.diverter, mv.diverter, c.diverter)
}
// Flip the client's diverter to prove the reference is live, not
// a copy.
c.diverter.SetSessionLoad(0.42)
if got := tbl.diverter.SessionLoad(); got != 0.42 {
t.Errorf("after SetSessionLoad(0.42), tbl.diverter.SessionLoad() = %v, want 0.42", got)
}
}
// ─── Session-backend-wired tests ──────────────────────────────────────
//
// Every NewClientWithConfig-produced Client now carries a real
// session.Client (it's always constructed), so the tests below
// substitute a lightweight fakeSessionClient to keep them dial-free
// and deterministic. The nil-sessionImpl case (below in
// TestGetOrCreateSession_NilSessionImplReturnsNil) is still a valid
// programmatic construction — internal helpers guard for it so
// hand-built or emulator-only Clients don't panic — even though the
// public factory path always wires one.
// fakeSessionClient is a minimal session.Client used by the tests
// below. Records which Open* helpers were called and returns a shared
// noopSessionTable so identity comparisons prove the cache works.
type fakeSessionClient struct {
openTableCalls []string
openAVCalls [][2]string
openMVCalls []string
closeCalls int
loadListenerSet bool
// One stub per resource-key so distinct resources get distinct
// handles but repeat opens of the same resource return the same
// pointer (cache-hit assertion in the tests).
handles map[string]*noopSessionTable
}
func newFakeSessionClient() *fakeSessionClient {
return &fakeSessionClient{handles: map[string]*noopSessionTable{}}
}
func (f *fakeSessionClient) handle(key string) session.TableAPI {
if h, ok := f.handles[key]; ok {
return h
}
h := &noopSessionTable{key: key}
f.handles[key] = h
return h
}
func (f *fakeSessionClient) OpenTable(t string) session.TableAPI {
f.openTableCalls = append(f.openTableCalls, t)
return f.handle("tbl:" + t)
}
func (f *fakeSessionClient) OpenAuthorizedView(t, v string) session.TableAPI {
f.openAVCalls = append(f.openAVCalls, [2]string{t, v})
return f.handle("av:" + t + ":" + v)
}
func (f *fakeSessionClient) OpenMaterializedView(v string) session.TableAPI {
f.openMVCalls = append(f.openMVCalls, v)
return f.handle("mv:" + v)
}
func (f *fakeSessionClient) MeterProvider() metric.MeterProvider { return nil }
func (f *fakeSessionClient) SessionDebug() btransport.SessionDebugProvider { return nil }
func (f *fakeSessionClient) ChannelDebug() btransport.ChannelDebugProvider { return nil }
func (f *fakeSessionClient) ConfigDebug() btransport.ConfigDebugProvider { return nil }
func (f *fakeSessionClient) AddSessionLoadListener(_ func(load float64)) func() {
f.loadListenerSet = true
return func() {}
}
func (f *fakeSessionClient) Close() error { f.closeCalls++; return nil }
// noopSessionTable is the session.TableAPI vended by fakeSessionClient.
// Both RPC methods are no-ops; the tests below only inspect identity
// (pointer equality) so cache-hit vs. cache-miss can be distinguished.
type noopSessionTable struct{ key string }
func (n *noopSessionTable) ReadRow(context.Context, *btpb.SessionReadRowRequest) (*btpb.SessionReadRowResponse, error) {
return &btpb.SessionReadRowResponse{}, nil
}
func (n *noopSessionTable) MutateRow(context.Context, *btpb.SessionMutateRowRequest) (*btpb.SessionMutateRowResponse, error) {
return &btpb.SessionMutateRowResponse{}, nil
}
func (n *noopSessionTable) Close() error { return nil }
func newSessionWiredClient(t *testing.T, fsc *fakeSessionClient) *Client {
t.Helper()
c := &Client{
project: "p",
instance: "i",
appProfile: "ap",
featureFlagsMD: metadata.MD{},
diverter: btransport.NewDiverter(0.0),
sessionImpl: fsc,
}
// Cache with a huge TTL and huge sweep interval so eviction never
// fires during these tests. Cache-behavior tests build their own
// cache with tight timings.
c.sessionTables = session.NewTableCache(24*time.Hour, 24*time.Hour, nil)
t.Cleanup(c.sessionTables.Close)
return c
}
// unwrap peels the session.TableHandle wrapper off a shim's session so
// tests can inspect the underlying fakeSessionClient-vended
// noopSessionTable directly.
func unwrapSession(t *testing.T, api session.TableAPI) *noopSessionTable {
t.Helper()
h, ok := api.(*session.TableHandle)
if !ok {
t.Fatalf("session = %T, want *session.TableHandle (cache wrapper)", api)
}
nst, ok := h.Unwrap().(*noopSessionTable)
if !ok {
t.Fatalf("handle.Unwrap() = %T, want *noopSessionTable (from fakeSessionClient)", h.Unwrap())
}
return nst
}
// TestOpenTable_WithSessionBackend_WiresSessionTableAPI pins that
// OpenTable's returned shim carries a non-nil session TableAPI produced
// by sessionImpl.OpenTable(table) — the default state now that
// NewClientWithConfig always wires a session.Client.
func TestOpenTable_WithSessionBackend_WiresSessionTableAPI(t *testing.T) {
fsc := newFakeSessionClient()
c := newSessionWiredClient(t, fsc)
shim := c.OpenTable("my-table").(*TableShim)
if shim.session == nil {
t.Fatal("OpenTable→TableShim.session = nil, want non-nil (session backend is wired)")
}
nst := unwrapSession(t, shim.session)
// noopSessionTable.key is fakeSessionClient's internal identity
// tag (prefixed inside fakeSessionClient.handle to disambiguate
// its own bookkeeping) — unrelated to what the cache uses as its
// key, which is now the fully-qualified resource name from
// c.fullTableName.
if nst.key != "tbl:my-table" {
t.Errorf("session key = %q, want %q", nst.key, "tbl:my-table")
}
if len(fsc.openTableCalls) != 1 || fsc.openTableCalls[0] != "my-table" {
t.Errorf("sessionImpl.OpenTable calls = %v, want [my-table]", fsc.openTableCalls)
}
}
// TestOpenTable_SessionTableAPICacheHit pins that a second OpenTable on
// the same table reuses the cached session TableAPI (identity check on
// the pointer) — the cache prevents leaking a fresh pair of read/write
// session pools per Open call.
func TestOpenTable_SessionTableAPICacheHit(t *testing.T) {
fsc := newFakeSessionClient()
c := newSessionWiredClient(t, fsc)
s1 := c.OpenTable("t").(*TableShim).session
s2 := c.OpenTable("t").(*TableShim).session
if s1 != s2 {
t.Errorf("cache miss on second OpenTable: s1=%p s2=%p (want identity)", s1, s2)
}
if len(fsc.openTableCalls) != 1 {
t.Errorf("sessionImpl.OpenTable called %d times, want 1 (cache miss)", len(fsc.openTableCalls))
}
}
// TestOpenAuthorizedView_SessionCacheIsTableQualified pins that two
// authorized views with the same view-id under DIFFERENT tables get
// DISTINCT session TableAPI instances — the cache key must be
// "av:<table>:<view>", not "av:<view>", so per-resource session pools
// and per-pool metric labels stay disjoint.
func TestOpenAuthorizedView_SessionCacheIsTableQualified(t *testing.T) {
fsc := newFakeSessionClient()
c := newSessionWiredClient(t, fsc)
sA := c.OpenAuthorizedView("tableA", "sharedView").(*TableShim).session
sB := c.OpenAuthorizedView("tableB", "sharedView").(*TableShim).session
if sA == sB {
t.Errorf("two AVs with the same view-id under different tables share a session TableAPI (identity match) — cache key must be table-qualified")
}
if len(fsc.openAVCalls) != 2 {
t.Errorf("sessionImpl.OpenAuthorizedView calls = %d, want 2 (once per (table, view))", len(fsc.openAVCalls))
}
}
// TestOpenMaterializedView_WithSessionBackend_WiresSessionTableAPI is
// the parallel to TestOpenTable_WithSessionBackend for MVs.
func TestOpenMaterializedView_WithSessionBackend_WiresSessionTableAPI(t *testing.T) {
fsc := newFakeSessionClient()
c := newSessionWiredClient(t, fsc)
shim := c.OpenMaterializedView("mv").(*TableShim)
if shim.session == nil {
t.Fatal("session = nil, want non-nil")
}
if len(fsc.openMVCalls) != 1 || fsc.openMVCalls[0] != "mv" {
t.Errorf("sessionImpl.OpenMaterializedView calls = %v, want [mv]", fsc.openMVCalls)
}
// Cache-hit check: second Open reuses the same session TableAPI.
if s2 := c.OpenMaterializedView("mv").(*TableShim).session; s2 != shim.session {
t.Errorf("cache miss on repeat OpenMaterializedView")
}
if len(fsc.openMVCalls) != 1 {
t.Errorf("sessionImpl.OpenMaterializedView called %d times, want 1 (repeat call must hit cache)", len(fsc.openMVCalls))
}
}
// TestGetOrCreateSession_NilSessionImplReturnsNil pins that with
// sessionImpl == nil, every getOrCreateSession* short-circuits to nil
// BEFORE touching the map. NewClientWithConfig always sets sessionImpl,
// but a hand-built Client (used here and in tests / emulator setups)
// can leave it nil — the guard exists so those paths don't panic.
func TestGetOrCreateSession_NilSessionImplReturnsNil(t *testing.T) {
c := newBareClientForOpenTests(t, 0.0) // sessionImpl unset
if got := c.getOrCreateSessionTable("t"); got != nil {
t.Errorf("getOrCreateSessionTable(nil sessionImpl) = %v, want nil", got)
}
if got := c.getOrCreateSessionAuthorizedView("t", "v"); got != nil {
t.Errorf("getOrCreateSessionAuthorizedView(nil sessionImpl) = %v, want nil", got)
}
if got := c.getOrCreateSessionMaterializedView("v"); got != nil {
t.Errorf("getOrCreateSessionMaterializedView(nil sessionImpl) = %v, want nil", got)
}
}