Files
seaweedfs/test/s3tables/admin/admin_iceberg_pages_test.go
T
Chris Lu 60e7b30009 admin: browse Iceberg table data (#10227)
* admin: move volume-server read JWT helper into dash

The Iceberg data preview page needs the same per-fileId read token the
file browser uses when streaming chunks from volume servers.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: add Iceberg table data preview page

The admin UI browses the Iceberg catalog down to table details but not
the data itself. Add a Browse Data page per table that walks the
selected snapshot's manifests and shows sample rows from its Parquet
data files, plus the data file list with per-file preview, a snapshot
switcher, and a row limit selector.

Rows are read through a ranged ReaderAt over stream-content so only
the Parquet footer and needed pages are fetched, with the volume read
JWT applied when configured. Iceberg locations resolve into /buckets
with traversal guards, and the file parameter must match a
manifest-listed data file. Snapshots with delete files get a warning
that raw rows are shown.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: integration test for Iceberg catalog and data preview pages

Starts a weed mini cluster with the admin UI, creates a table bucket,
namespace, and tables via the S3 Tables manager, uploads real Parquet
files via S3, writes manifests and snapshots with iceberg-go, and
asserts on the rendered pages: catalog browsing, table details,
current and historical snapshot previews, per-file preview, row
limits, unknown snapshot and file errors, and a metadata-less table.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: write Iceberg preview chunk reads straight into the caller slice

ReadAt wrapped the caller's buffer in a bytes.Buffer, which would
silently allocate a fresh backing array and drop bytes if it ever grew.
Copy directly into the destination slice and reject negative offsets so
the ReaderAt contract holds.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: link to snapshot history when the preview switcher truncates

The snapshot switcher caps at 25 entries; add a trailing item pointing
at the table details page so older snapshots stay reachable.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* test: hoist mini cluster context assignment out of the goroutine

Set MiniClusterCtx before launching the cluster goroutine and clear it
in stop(), so the assignment is not buried in the command loop.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur
2026-07-03 14:02:44 -07:00

560 lines
20 KiB
Go

// Package admin contains integration tests for the admin UI's Iceberg
// catalog and data-preview pages. Tests start a real weed mini cluster
// (with the admin UI enabled), create a table bucket, namespace, and table
// via the S3 Tables manager, populate real Parquet data files, manifests,
// and snapshots via S3 and the filer gRPC API, and then assert on the HTML
// the admin server renders.
package admin
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/parquet-go/parquet-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/seaweedfs/seaweedfs/weed/command"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// ---------------------------------------------------------------------------
// Cluster lifecycle (mirrors test/s3tables/maintenance)
// ---------------------------------------------------------------------------
type testCluster struct {
dataDir string
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
filerGrpcPort int
s3Port int
adminPort int
s3Endpoint string
adminEndpoint string
isRunning bool
}
var shared *testCluster
func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
os.Exit(m.Run())
}
testDir, err := os.MkdirTemp("", "seaweed-admin-iceberg-*")
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: failed to create temp dir: %v\n", err)
os.Exit(0)
}
cluster, err := startCluster(testDir)
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: failed to start cluster: %v\n", err)
os.RemoveAll(testDir)
os.Exit(0)
}
shared = cluster
code := m.Run()
shared.stop()
os.RemoveAll(testDir)
os.Exit(code)
}
func startCluster(testDir string) (*testCluster, error) {
ports, err := testutil.AllocatePorts(10)
if err != nil {
return nil, err
}
masterPort, masterGrpc := ports[0], ports[1]
volumePort, volumeGrpc := ports[2], ports[3]
filerPort, filerGrpc := ports[4], ports[5]
s3Port, s3Grpc := ports[6], ports[7]
adminPort, adminGrpc := ports[8], ports[9]
// Empty security.toml disables JWT auth.
if err := os.WriteFile(filepath.Join(testDir, "security.toml"), []byte("# test\n"), 0644); err != nil {
return nil, err
}
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
os.Setenv("AWS_ACCESS_KEY_ID", "admin")
}
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
os.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
}
ctx, cancel := context.WithCancel(context.Background())
c := &testCluster{
dataDir: testDir,
ctx: ctx,
cancel: cancel,
filerGrpcPort: filerGrpc,
s3Port: s3Port,
adminPort: adminPort,
s3Endpoint: fmt.Sprintf("http://127.0.0.1:%d", s3Port),
adminEndpoint: fmt.Sprintf("http://127.0.0.1:%d", adminPort),
}
// Set on the package global before the goroutine starts so the mini
// command picks it up; cleared in stop(). The global is how the mini
// harness receives its cancellation context in tests.
command.MiniClusterCtx = ctx //nolint:fatcontext // test harness handoff, not a per-request context
c.wg.Add(1)
go func() {
defer c.wg.Done()
oldDir, _ := os.Getwd()
oldArgs := os.Args
defer func() { os.Chdir(oldDir); os.Args = oldArgs }()
os.Chdir(testDir)
args := []string{
"-dir=" + testDir,
"-master.dir=" + testDir,
"-master.port=" + strconv.Itoa(masterPort),
"-master.port.grpc=" + strconv.Itoa(masterGrpc),
"-volume.port=" + strconv.Itoa(volumePort),
"-volume.port.grpc=" + strconv.Itoa(volumeGrpc),
"-volume.port.public=" + strconv.Itoa(volumePort),
"-volume.publicUrl=127.0.0.1:" + strconv.Itoa(volumePort),
"-filer.port=" + strconv.Itoa(filerPort),
"-filer.port.grpc=" + strconv.Itoa(filerGrpc),
"-s3.port=" + strconv.Itoa(s3Port),
"-s3.port.grpc=" + strconv.Itoa(s3Grpc),
"-admin.port=" + strconv.Itoa(adminPort),
"-admin.port.grpc=" + strconv.Itoa(adminGrpc),
"-webdav.port=0",
"-master.volumeSizeLimitMB=32",
"-ip=127.0.0.1",
"-master.peers=none",
"-s3.iam.readOnly=false",
}
os.Args = append([]string{"weed"}, args...)
glog.MaxSize = 1024 * 1024
for _, cmd := range command.Commands {
if cmd.Name() == "mini" && cmd.Run != nil {
cmd.Flag.Parse(os.Args[1:])
cmd.Run(cmd, cmd.Flag.Args())
return
}
}
}()
if err := waitReady(c.s3Endpoint, 30*time.Second); err != nil {
cancel()
return nil, err
}
if err := waitReady(c.adminEndpoint, 30*time.Second); err != nil {
cancel()
return nil, err
}
c.isRunning = true
return c, nil
}
func (c *testCluster) stop() {
command.MiniClusterCtx = nil //nolint:fatcontext // clearing the test harness handoff
if c.cancel != nil {
c.cancel()
}
if c.isRunning {
time.Sleep(500 * time.Millisecond)
}
done := make(chan struct{})
go func() { c.wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
}
}
func (c *testCluster) filerConn(t *testing.T) filer_pb.SeaweedFilerClient {
t.Helper()
addr := fmt.Sprintf("127.0.0.1:%d", c.filerGrpcPort)
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { conn.Close() })
return filer_pb.NewSeaweedFilerClient(conn)
}
func waitReady(endpoint string, timeout time.Duration) error {
client := &http.Client{Timeout: 1 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(endpoint)
if err == nil {
resp.Body.Close()
time.Sleep(500 * time.Millisecond)
return nil
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for %s", endpoint)
}
func randomSuffix() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// ---------------------------------------------------------------------------
// Catalog and table population
// ---------------------------------------------------------------------------
type previewRow struct {
ID int64 `parquet:"id"`
Name string `parquet:"name"`
}
func buildParquet(t *testing.T, rows []previewRow) []byte {
t.Helper()
var buf bytes.Buffer
writer := parquet.NewGenericWriter[previewRow](&buf)
_, err := writer.Write(rows)
require.NoError(t, err)
require.NoError(t, writer.Close())
return buf.Bytes()
}
func newS3Client(t *testing.T, endpoint string) *s3.Client {
t.Helper()
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("admin", "admin", "")),
)
require.NoError(t, err)
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
}
type catalogFixture struct {
bucket string
namespace string
table string
snap1ID int64
snap2ID int64
file1Path string // manifest-listed location of the first data file (relative)
file2Path string // manifest-listed location of the second data file (s3:// URI)
}
// setupCatalog creates a table bucket, namespace, and two tables via the S3
// Tables manager: "events" with two snapshots backed by real Parquet files,
// and "empty" with no Iceberg metadata.
func setupCatalog(t *testing.T, client filer_pb.SeaweedFilerClient) catalogFixture {
t.Helper()
ctx := context.Background()
fx := catalogFixture{
bucket: "admin-ui-" + randomSuffix(),
namespace: "analytics",
table: "events",
snap1ID: 3001,
snap2ID: 3002,
}
fx.file1Path = "data/p1.parquet"
fx.file2Path = fmt.Sprintf("s3://%s/%s/%s/data/p2.parquet", fx.bucket, fx.namespace, fx.table)
mgr := s3tables.NewManager()
mgr.SetAccountID(s3_constants.AccountAdminId)
mc := s3tables.NewManagerClient(client)
exec := func(op string, req, resp interface{}) {
t.Helper()
require.NoError(t, mgr.Execute(ctx, mc, op, req, resp, s3_constants.AccountAdminId), "s3tables %s", op)
}
var bucketResp s3tables.CreateTableBucketResponse
exec("CreateTableBucket", &s3tables.CreateTableBucketRequest{Name: fx.bucket}, &bucketResp)
exec("CreateNamespace", &s3tables.CreateNamespaceRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}}, &s3tables.CreateNamespaceResponse{})
exec("CreateTable", &s3tables.CreateTableRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}, Name: fx.table, Format: "ICEBERG"}, &s3tables.CreateTableResponse{})
exec("CreateTable", &s3tables.CreateTableRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}, Name: "empty", Format: "ICEBERG"}, &s3tables.CreateTableResponse{})
// Upload real Parquet data files via S3 so they are chunk-backed entries.
s3Client := newS3Client(t, shared.s3Endpoint)
tableKey := path.Join(fx.namespace, fx.table)
rows1 := []previewRow{{1, "p1-row-1"}, {2, "p1-row-2"}, {3, "p1-row-3"}, {4, "p1-row-4"}, {5, "p1-row-5"}}
rows2 := []previewRow{{6, "p2-row-6"}, {7, "p2-row-7"}, {8, "p2-row-8"}}
parquet1 := buildParquet(t, rows1)
parquet2 := buildParquet(t, rows2)
putObject(t, s3Client, fx.bucket, path.Join(tableKey, "data/p1.parquet"), parquet1)
putObject(t, s3Client, fx.bucket, path.Join(tableKey, "data/p2.parquet"), parquet2)
// Build table metadata with two snapshots. Snapshot 2's manifest list is
// cumulative (both manifests) as engines write it.
location := fmt.Sprintf("s3://%s/%s/%s", fx.bucket, fx.namespace, fx.table)
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
iceberg.NestedField{ID: 2, Type: iceberg.PrimitiveTypes.String, Name: "name", Required: false},
)
meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, location, nil)
require.NoError(t, err)
baseMs := time.Now().UnixMilli() + 100
snap1 := table.Snapshot{SnapshotID: fx.snap1ID, SequenceNumber: 1, TimestampMs: baseMs, ManifestList: "metadata/snap-3001.avro"}
snap2 := table.Snapshot{SnapshotID: fx.snap2ID, ParentSnapshotID: &fx.snap1ID, SequenceNumber: 2, TimestampMs: baseMs + 1, ManifestList: "metadata/snap-3002.avro"}
builder, err := table.MetadataBuilderFromBase(meta, location)
require.NoError(t, err)
require.NoError(t, builder.AddSnapshot(&snap1))
require.NoError(t, builder.AddSnapshot(&snap2))
require.NoError(t, builder.SetSnapshotRef(table.MainBranch, fx.snap2ID, table.BranchRef))
meta, err = builder.Build()
require.NoError(t, err)
// Write manifests and manifest lists into the table's metadata directory.
metaDir := path.Join(s3tables.TablesPath, fx.bucket, fx.namespace, fx.table, "metadata")
spec := meta.PartitionSpec()
version := meta.Version()
mf1 := writeManifest(t, ctx, client, metaDir, "manifest-3001.avro", version, spec, schema, fx.snap1ID, fx.file1Path, int64(len(rows1)), int64(len(parquet1)))
mf2 := writeManifest(t, ctx, client, metaDir, "manifest-3002.avro", version, spec, schema, fx.snap2ID, fx.file2Path, int64(len(rows2)), int64(len(parquet2)))
list1 := writeManifestList(t, ctx, client, metaDir, "snap-3001.avro", version, fx.snap1ID, nil, 1, []iceberg.ManifestFile{mf1})
// Re-read snapshot 1's list so mf1 carries its assigned sequence number,
// which the cumulative snapshot 2 list requires.
assigned, err := iceberg.ReadManifestList(bytes.NewReader(list1))
require.NoError(t, err)
require.Len(t, assigned, 1)
writeManifestList(t, ctx, client, metaDir, "snap-3002.avro", version, fx.snap2ID, &fx.snap1ID, 2, []iceberg.ManifestFile{assigned[0], mf2})
// Point the table's catalog xattr at the new metadata.
fullJSON, err := json.Marshal(meta)
require.NoError(t, err)
writeFile(t, ctx, client, metaDir, "v2.metadata.json", fullJSON)
updateTableMetadataXattr(t, ctx, client, fx, fullJSON)
// Snapshot timestamps sit slightly in the future; let them pass.
time.Sleep(200 * time.Millisecond)
return fx
}
func putObject(t *testing.T, client *s3.Client, bucket, key string, body []byte) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: bytes.NewReader(body),
})
require.NoError(t, err)
}
func writeManifest(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, metaDir, name string, version int, spec iceberg.PartitionSpec, schema *iceberg.Schema, snapID int64, dataFilePath string, recordCount, fileSize int64) iceberg.ManifestFile {
t.Helper()
dfBuilder, err := iceberg.NewDataFileBuilder(
spec, iceberg.EntryContentData,
dataFilePath, iceberg.ParquetFile,
map[int]any{}, nil, nil, recordCount, fileSize,
)
require.NoError(t, err)
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
var buf bytes.Buffer
mf, err := iceberg.WriteManifest(path.Join("metadata", name), &buf, version, spec, schema, snapID, []iceberg.ManifestEntry{entry})
require.NoError(t, err)
writeFile(t, ctx, client, metaDir, name, buf.Bytes())
return mf
}
func writeManifestList(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, metaDir, name string, version int, snapID int64, parent *int64, seqNum int64, files []iceberg.ManifestFile) []byte {
t.Helper()
var buf bytes.Buffer
require.NoError(t, iceberg.WriteManifestList(version, &buf, snapID, parent, &seqNum, 0, files))
writeFile(t, ctx, client, metaDir, name, buf.Bytes())
return buf.Bytes()
}
func writeFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
t.Helper()
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{
Mtime: time.Now().Unix(), Crtime: time.Now().Unix(),
FileMode: uint32(0644), FileSize: uint64(len(content)),
},
Content: content,
},
})
require.NoError(t, err, "writeFile(%s, %s)", dir, name)
require.Empty(t, resp.Error, "writeFile(%s, %s)", dir, name)
}
func updateTableMetadataXattr(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, fx catalogFixture, fullJSON []byte) {
t.Helper()
nsDir := path.Join(s3tables.TablesPath, fx.bucket, fx.namespace)
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{Directory: nsDir, Name: fx.table})
require.NoError(t, err)
require.NotNil(t, resp.Entry)
var internalMeta map[string]json.RawMessage
require.NoError(t, json.Unmarshal(resp.Entry.Extended[s3tables.ExtendedKeyMetadata], &internalMeta))
metaObj := map[string]json.RawMessage{}
if raw, ok := internalMeta["metadata"]; ok {
require.NoError(t, json.Unmarshal(raw, &metaObj))
}
metaObj["fullMetadata"] = fullJSON
metaJSON, err := json.Marshal(metaObj)
require.NoError(t, err)
internalMeta["metadata"] = metaJSON
internalMeta["metadataVersion"] = json.RawMessage("2")
internalMeta["metadataLocation"] = json.RawMessage(`"metadata/v2.metadata.json"`)
xattr, err := json.Marshal(internalMeta)
require.NoError(t, err)
resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = xattr
resp.Entry.Extended[s3tables.ExtendedKeyMetadataVersion] = []byte("2")
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{Directory: nsDir, Entry: resp.Entry})
require.NoError(t, err)
}
// ---------------------------------------------------------------------------
// Page fetching
// ---------------------------------------------------------------------------
func fetchPage(t *testing.T, pagePath string) string {
t.Helper()
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(shared.adminEndpoint + pagePath)
require.NoError(t, err, "GET %s", pagePath)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode, "GET %s: %s", pagePath, string(body))
return string(body)
}
// fetchPageUntil re-fetches a page until the predicate passes, tolerating the
// admin server's filer-discovery delay right after startup.
func fetchPageUntil(t *testing.T, pagePath string, predicate func(string) bool) string {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
var body string
for time.Now().Before(deadline) {
body = fetchPage(t, pagePath)
if predicate(body) {
return body
}
time.Sleep(500 * time.Millisecond)
}
return body
}
func tablePagePath(fx catalogFixture, tableName, suffix string) string {
return fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables/%s%s",
url.PathEscape(fx.bucket), url.PathEscape(fx.namespace), url.PathEscape(tableName), suffix)
}
// ---------------------------------------------------------------------------
// Integration tests
// ---------------------------------------------------------------------------
func TestAdminIcebergPages(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
client := shared.filerConn(t)
fx := setupCatalog(t, client)
t.Run("CatalogBrowsing", func(t *testing.T) {
buckets := fetchPageUntil(t, "/object-store/s3tables/buckets", func(body string) bool {
return strings.Contains(body, fx.bucket)
})
assert.Contains(t, buckets, fx.bucket)
namespaces := fetchPage(t, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces", url.PathEscape(fx.bucket)))
assert.Contains(t, namespaces, fx.namespace)
tables := fetchPage(t, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables", url.PathEscape(fx.bucket), url.PathEscape(fx.namespace)))
assert.Contains(t, tables, fx.table)
assert.Contains(t, tables, "empty")
})
t.Run("TableDetails", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, ""))
assert.Contains(t, body, "Browse Data")
assert.Contains(t, body, "/data")
assert.Contains(t, body, ">id<")
assert.Contains(t, body, ">name<")
assert.Contains(t, body, strconv.FormatInt(fx.snap1ID, 10))
assert.Contains(t, body, strconv.FormatInt(fx.snap2ID, 10))
})
t.Run("DataPreviewCurrentSnapshot", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data"))
assert.Contains(t, body, "Showing 8 row(s) from 2 data file(s).")
assert.Contains(t, body, "p1-row-1")
assert.Contains(t, body, "p2-row-8")
assert.Contains(t, body, fx.file1Path)
assert.Contains(t, body, fx.file2Path)
assert.Contains(t, body, "current")
})
t.Run("DataPreviewOldSnapshot", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?snapshot="+strconv.FormatInt(fx.snap1ID, 10)))
assert.Contains(t, body, "Showing 5 row(s) from 1 data file(s).")
assert.Contains(t, body, "p1-row-5")
assert.NotContains(t, body, "p2-row-6")
})
t.Run("DataPreviewSingleFile", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?file="+url.QueryEscape(fx.file2Path)))
assert.Contains(t, body, "Showing 3 row(s) from 1 data file(s).")
assert.Contains(t, body, "p2-row-6")
assert.NotContains(t, body, "p1-row-1")
})
t.Run("DataPreviewRowLimit", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?limit=3"))
assert.Contains(t, body, "Showing 3 row(s) from 1 data file(s).")
})
t.Run("DataPreviewUnknownFile", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?file="+url.QueryEscape("s3://elsewhere/x.parquet")))
assert.Contains(t, body, "Requested data file is not part of this snapshot.")
})
t.Run("DataPreviewUnknownSnapshot", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?snapshot=42"))
assert.Contains(t, body, "Snapshot 42 not found.")
})
t.Run("DataPreviewTableWithoutMetadata", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, "empty", "/data"))
assert.Contains(t, body, "Table has no Iceberg metadata.")
})
}