This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-6823-b6abb0790dca9dcf729f4d958c22ebc52fdd8f51
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 9ca9c57eb8b7bf4b11c23269102ca737ce6f0181
Author: Xinyuan Lin <[email protected]>
AuthorDate: Thu Jul 23 22:52:01 2026 -0700

    test(workflow-core): add storage unit test coverage (DocumentFactory, 
DatasetFileDocument, IcebergDocument) (#6823)
    
    ### What changes were proposed in this PR?
    
    Adds unit coverage for three workflow-core storage classes (all verified
    with the JBR-17 sbt toolchain; no source changes).
    - `DocumentFactorySpec` (extended): the full scheme-dispatch surface of
    `openReadonlyDocument`/`openDocument`/`createDocument`/`documentExists`
    — dataset/file/vfs arms, the unsupported-scheme guards (each asserting
    the operation-specific message), and the vfs create/open/exists happy
    paths across the RESULT/STATE/CONSOLE_MESSAGES/RUNTIME_STATISTICS
    namespaces, against a real local Iceberg catalog.
    - `DatasetFileDocumentSpec` (extended): the companion-object
    env-fallback lazy vals (presign-URL endpoint default, empty JWT
    default). The LakeFS/network-backed read paths are left uncovered as
    they need a live LakeFS.
    - `IcebergDocumentSpec` (new, unit-level — not `@IntegrationTest`):
    `getURI`, `getCount`, `get`/`getRange`/`getAfter` (with column
    projection + multi-file ordering + iterator exhaustion),
    `getTableStatistics`, `getTotalFileSize`, `asInputStream` (ZIP with
    parquet entries), and `clear` — driven against a real local Hadoop
    Iceberg catalog via a new `LocalHadoopIcebergCatalog` test helper.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6821.
    
    ### How was this PR tested?
    
    `sbt -java-home <jbr-17> "WorkflowCore/testOnly *DocumentFactorySpec
    *DatasetFileDocumentSpec *IcebergDocumentSpec"` -> 49 succeeded, 0
    failed. scalafmtCheckAll + scalafixAll --check clean.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../amber/core/storage/DocumentFactorySpec.scala   | 199 ++++++++++++-
 .../core/storage/LocalHadoopIcebergCatalog.scala   |  66 ++++
 .../storage/model/DatasetFileDocumentSpec.scala    |  24 ++
 .../result/iceberg/IcebergDocumentSpec.scala       | 331 +++++++++++++++++++++
 4 files changed, 611 insertions(+), 9 deletions(-)

diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala
index d67b90f363..ad8b5580c5 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala
@@ -19,27 +19,55 @@
 
 package org.apache.texera.amber.core.storage
 
-import org.apache.texera.amber.core.storage.model.VirtualDocument
-import org.apache.texera.amber.core.tuple.{Schema, Tuple}
+import org.apache.texera.amber.core.storage.model.{
+  DatasetFileDocument,
+  ReadonlyLocalFileDocument,
+  VirtualDocument
+}
+import org.apache.texera.amber.core.storage.result.iceberg.IcebergDocument
+import org.apache.texera.amber.core.tuple.{AttributeType, Schema, Tuple}
+import org.apache.texera.amber.core.virtualidentity.{
+  ExecutionIdentity,
+  OperatorIdentity,
+  PhysicalOpIdentity,
+  WorkflowIdentity
+}
+import org.apache.texera.amber.core.workflow.{GlobalPortIdentity, PortIdentity}
+import org.scalatest.BeforeAndAfterAll
 import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 
 import java.net.URI
+import java.nio.file.Files
+import java.util.UUID
 
 /**
-  * Unit tests for `DocumentFactory.createOrReuseDocument`, the create-or-reuse
-  * decision behind output-port storage provisioning. It always returns the
-  * document (opened when reused, created otherwise) so the call site doesn't
-  * branch.
+  * Unit tests for `DocumentFactory`.
   *
-  * `exists` / `open` / `create` are injected so the decision can be pinned 
with
-  * trivial document stubs -- no iceberg backend, no live region.
+  * The first group pins `createOrReuseDocument`, the create-or-reuse decision
+  * behind output-port storage provisioning, using injected 
`exists`/`open`/`create`
+  * spies -- no iceberg backend, no live region.
+  *
+  * The remaining groups exercise the scheme-dispatch surface of
+  * `openReadonlyDocument` / `openDocument` / `createDocument` / 
`documentExists`:
+  * the dataset / file / vfs scheme arms, the unsupported-scheme guards, and 
the
+  * vfs create/open/exists happy paths against a local Hadoop-backed Iceberg
+  * catalog installed via [[LocalHadoopIcebergCatalog]].
   */
-class DocumentFactorySpec extends AnyFlatSpec with Matchers {
+class DocumentFactorySpec extends AnyFlatSpec with Matchers with 
BeforeAndAfterAll {
 
   private val uri = new URI("vfs:///wf/result/loop-end")
   private val schema = Schema()
 
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    LocalHadoopIcebergCatalog.ensure()
+  }
+
+  // 
---------------------------------------------------------------------------
+  // createOrReuseDocument
+  // 
---------------------------------------------------------------------------
+
   private def stubDoc: VirtualDocument[Tuple] =
     new VirtualDocument[Tuple] {
       override def getURI: URI = uri
@@ -93,4 +121,157 @@ class DocumentFactorySpec extends AnyFlatSpec with 
Matchers {
     )
     probed shouldBe false
   }
+
+  // 
---------------------------------------------------------------------------
+  // openReadonlyDocument -- scheme dispatch
+  // 
---------------------------------------------------------------------------
+
+  private val versionHash = "97fd4c2a755b69b7c66d322eab40b7e5c2ad5d10"
+
+  "openReadonlyDocument" should "return a DatasetFileDocument for the dataset 
scheme" in {
+    val datasetUri = new URI(s"dataset:///repo/$versionHash/file.txt")
+    val doc = DocumentFactory.openReadonlyDocument(datasetUri)
+    doc shouldBe a[DatasetFileDocument]
+    doc.getURI shouldBe datasetUri
+  }
+
+  it should "return a ReadonlyLocalFileDocument for the file scheme" in {
+    val tempFile = Files.createTempFile("doc-factory-spec", ".txt")
+    try {
+      val doc = DocumentFactory.openReadonlyDocument(tempFile.toUri)
+      doc shouldBe a[ReadonlyLocalFileDocument]
+      doc.getURI shouldBe tempFile.toUri
+    } finally {
+      Files.deleteIfExists(tempFile)
+    }
+  }
+
+  it should "reject an unsupported scheme with a descriptive message" in {
+    val thrown = intercept[UnsupportedOperationException] {
+      DocumentFactory.openReadonlyDocument(new URI("ftp://host/path";))
+    }
+    thrown.getMessage should include("ftp")
+    thrown.getMessage should include("ReadonlyDocument")
+  }
+
+  // 
---------------------------------------------------------------------------
+  // openDocument / createDocument / documentExists -- unsupported schemes
+  // 
---------------------------------------------------------------------------
+
+  "openDocument" should "return a DatasetFileDocument and no schema for the 
dataset scheme" in {
+    val datasetUri = new URI(s"dataset:///repo/$versionHash/file.txt")
+    val (doc, schemaOpt) = DocumentFactory.openDocument(datasetUri)
+    doc shouldBe a[DatasetFileDocument]
+    schemaOpt shouldBe None
+  }
+
+  it should "reject an unsupported scheme" in {
+    val thrown = intercept[UnsupportedOperationException] {
+      DocumentFactory.openDocument(new URI("ftp://host/path";))
+    }
+    thrown.getMessage should include("opening the document")
+  }
+
+  "createDocument" should "reject an unsupported scheme" in {
+    val thrown = intercept[UnsupportedOperationException] {
+      DocumentFactory.createDocument(
+        new URI("file:///tmp/x"),
+        Schema().add("v", AttributeType.INTEGER)
+      )
+    }
+    thrown.getMessage should include("creating the document")
+  }
+
+  "documentExists" should "reject an unsupported scheme" in {
+    val thrown = intercept[UnsupportedOperationException] {
+      DocumentFactory.documentExists(new URI("file:///tmp/x"))
+    }
+    thrown.getMessage should include("checking document existence")
+  }
+
+  // 
---------------------------------------------------------------------------
+  // vfs scheme -- create / open / exists against a local Iceberg catalog
+  // 
---------------------------------------------------------------------------
+
+  private val vfsSchema: Schema = Schema().add("v", AttributeType.INTEGER)
+
+  private def freshResultURI(): URI = {
+    val base = VFSURIFactory.createPortBaseURI(
+      WorkflowIdentity(0),
+      ExecutionIdentity(0),
+      GlobalPortIdentity(
+        PhysicalOpIdentity(
+          logicalOpId = 
OperatorIdentity(s"op-${UUID.randomUUID().toString.replace("-", "")}"),
+          layerName = "main"
+        ),
+        PortIdentity()
+      )
+    )
+    VFSURIFactory.resultURI(base)
+  }
+
+  "createDocument" should "create an IcebergDocument for a vfs result URI" in {
+    val vfsUri = freshResultURI()
+    val doc = DocumentFactory.createDocument(vfsUri, vfsSchema)
+    doc shouldBe an[IcebergDocument[_]]
+    DocumentFactory.documentExists(vfsUri) shouldBe true
+  }
+
+  it should "create a state document for a vfs state URI (STATE namespace 
arm)" in {
+    val base = VFSURIFactory.createPortBaseURI(
+      WorkflowIdentity(0),
+      ExecutionIdentity(0),
+      GlobalPortIdentity(
+        PhysicalOpIdentity(
+          logicalOpId = 
OperatorIdentity(s"op-${UUID.randomUUID().toString.replace("-", "")}"),
+          layerName = "main"
+        ),
+        PortIdentity()
+      )
+    )
+    val stateUri = VFSURIFactory.stateURI(base)
+    val doc = DocumentFactory.createDocument(stateUri, vfsSchema)
+    doc shouldBe an[IcebergDocument[_]]
+    DocumentFactory.documentExists(stateUri) shouldBe true
+  }
+
+  "documentExists" should "report false before creation and true after for a 
vfs URI" in {
+    val vfsUri = freshResultURI()
+    DocumentFactory.documentExists(vfsUri) shouldBe false
+    DocumentFactory.createDocument(vfsUri, vfsSchema)
+    DocumentFactory.documentExists(vfsUri) shouldBe true
+  }
+
+  it should "resolve the CONSOLE_MESSAGES namespace" in {
+    val consoleUri = VFSURIFactory.createConsoleMessagesURI(
+      WorkflowIdentity(0),
+      ExecutionIdentity(0),
+      OperatorIdentity(s"op-${UUID.randomUUID().toString.replace("-", "")}")
+    )
+    DocumentFactory.documentExists(consoleUri) shouldBe false
+  }
+
+  it should "resolve the RUNTIME_STATISTICS namespace" in {
+    val statsUri = VFSURIFactory.createRuntimeStatisticsURI(
+      WorkflowIdentity(0),
+      ExecutionIdentity(0)
+    )
+    DocumentFactory.documentExists(statsUri) shouldBe false
+  }
+
+  "openDocument" should "open a created vfs document and return its schema" in 
{
+    val vfsUri = freshResultURI()
+    DocumentFactory.createDocument(vfsUri, vfsSchema)
+    val (doc, schemaOpt) = DocumentFactory.openDocument(vfsUri)
+    doc shouldBe an[IcebergDocument[_]]
+    schemaOpt shouldBe defined
+    schemaOpt.get.getAttributes.map(_.getName) should contain("v")
+  }
+
+  it should "throw IllegalArgumentException when opening a vfs URI with no 
backing storage" in {
+    val thrown = intercept[IllegalArgumentException] {
+      DocumentFactory.openDocument(freshResultURI())
+    }
+    thrown.getMessage should include("No storage is found")
+  }
 }
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/LocalHadoopIcebergCatalog.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/LocalHadoopIcebergCatalog.scala
new file mode 100644
index 0000000000..3b92b763a3
--- /dev/null
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/LocalHadoopIcebergCatalog.scala
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.texera.amber.core.storage
+
+import org.apache.texera.amber.util.IcebergUtil
+
+import java.nio.file.{Files, Path}
+import java.util.Comparator
+
+/**
+  * Test-only helper that installs a local Hadoop-backed Iceberg catalog into 
the
+  * shared `IcebergCatalogInstance` singleton exactly once per JVM.
+  *
+  * The default configured catalog type is `rest`, which needs a live REST 
server;
+  * unit tests instead exercise Iceberg against a temp-directory `file:/` 
warehouse
+  * (via the merged winutils-free local filesystem on Windows).
+  *
+  * `IcebergCatalogInstance` is a JVM-wide mutable singleton and ScalaTest runs
+  * suites in the module in parallel within the same JVM, so every suite that 
needs
+  * an Iceberg backend calls [[ensure]]. Initialization is idempotent and 
guarded by
+  * a single flag on this shared object, so the very first caller wins and all
+  * suites end up sharing the same catalog + warehouse instead of racing on
+  * `replaceInstance`. Suites keep their tables disjoint via distinct 
namespaces and
+  * unique (UUID) table names.
+  */
+object LocalHadoopIcebergCatalog {
+
+  private var initialized = false
+
+  def ensure(): Unit =
+    synchronized {
+      if (!initialized) {
+        val warehouse = Files.createTempDirectory("wfcore-iceberg-shared")
+        // Best-effort recursive cleanup of the temp warehouse on JVM exit so 
test runs
+        // don't leave wfcore-iceberg-shared* directories behind on dev 
machines / CI.
+        sys.addShutdownHook {
+          try Files
+            .walk(warehouse)
+            .sorted(Comparator.reverseOrder[Path]())
+            .forEach((p: Path) => Files.deleteIfExists(p))
+          catch { case _: Throwable => () }
+        }
+        IcebergCatalogInstance.replaceInstance(
+          IcebergUtil.createHadoopCatalog("wfcore-test", warehouse)
+        )
+        initialized = true
+      }
+    }
+}
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala
index da3b799b63..c16d417a73 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala
@@ -19,6 +19,7 @@
 
 package org.apache.texera.amber.core.storage.model
 
+import org.apache.texera.common.config.EnvironmentalVariable
 import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 
@@ -140,4 +141,27 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with 
Matchers {
       thrown.getMessage shouldBe "URI format is incorrect"
     }
   }
+
+  // The companion object resolves the file-service endpoint and the user JWT
+  // token from environment variables, falling back to a trimmed default. These
+  // lazy vals are read whenever asInputStream needs to fetch a file; assert 
their
+  // fallback behavior without requiring a live FileService or LakeFS. The 
checks
+  // are guarded so they hold regardless of whether the env overrides are 
present.
+  "DatasetFileDocument companion" should
+    "expose the default presigned-URL endpoint when the env override is 
absent" in {
+    val expected =
+      sys.env
+        .getOrElse(
+          EnvironmentalVariable.ENV_FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT,
+          "http://localhost:9092/api/dataset/presign-download";
+        )
+        .trim
+    DatasetFileDocument.fileServiceGetPresignURLEndpoint shouldBe expected
+  }
+
+  it should "expose a trimmed user JWT token defaulting to empty" in {
+    val expected =
+      sys.env.getOrElse(EnvironmentalVariable.ENV_USER_JWT_TOKEN, "").trim
+    DatasetFileDocument.userJwtToken shouldBe expected
+  }
 }
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocumentSpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocumentSpec.scala
new file mode 100644
index 0000000000..a1f4ea3023
--- /dev/null
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocumentSpec.scala
@@ -0,0 +1,331 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.texera.amber.core.storage.result.iceberg
+
+import org.apache.texera.amber.core.storage.IcebergCatalogInstance
+import org.apache.texera.amber.core.storage.LocalHadoopIcebergCatalog
+import org.apache.texera.amber.core.storage.model.VirtualDocument
+import org.apache.texera.amber.core.tuple.{AttributeType, Schema, Tuple}
+import org.apache.texera.amber.util.IcebergUtil
+import org.apache.iceberg.catalog.TableIdentifier
+import org.apache.iceberg.data.Record
+import org.apache.iceberg.exceptions.NoSuchTableException
+import org.apache.iceberg.{Schema => IcebergSchema}
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.sql.Timestamp
+import java.util.UUID
+import java.util.zip.ZipInputStream
+
+/**
+  * Unit-level tests for [[IcebergDocument]] running against a local 
Hadoop-backed
+  * Iceberg catalog (temp `file:/` warehouse) installed into the shared
+  * `IcebergCatalogInstance` singleton via [[LocalHadoopIcebergCatalog]].
+  *
+  * `IcebergDocument` reads its catalog from 
`IcebergCatalogInstance.getInstance()`,
+  * so the catalog must be installed before any document access. Each test 
creates a
+  * fresh, uniquely-named table so the read/write/count/clear paths are 
isolated.
+  */
+class IcebergDocumentSpec extends AnyFlatSpec with Matchers with 
BeforeAndAfterAll {
+
+  private val tableNamespace = "iceberg_doc_spec"
+
+  private val amberSchema: Schema = Schema()
+    .add("id", AttributeType.INTEGER)
+    .add("amount", AttributeType.LONG)
+    .add("score", AttributeType.DOUBLE)
+    .add("name", AttributeType.STRING)
+    .add("ts", AttributeType.TIMESTAMP)
+
+  private val icebergSchema: IcebergSchema = 
IcebergUtil.toIcebergSchema(amberSchema)
+
+  private val serde: (IcebergSchema, Tuple) => Record = 
IcebergUtil.toGenericRecord
+  private val deserde: (IcebergSchema, Record) => Tuple =
+    (schema, record) => IcebergUtil.fromRecord(record, 
IcebergUtil.fromIcebergSchema(schema))
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    LocalHadoopIcebergCatalog.ensure()
+  }
+
+  private def freshTableName(): String =
+    s"tbl_${UUID.randomUUID().toString.replace("-", "")}"
+
+  /** Create the backing table and return a document handle for it. */
+  private def newDocument(tableName: String = freshTableName()): 
IcebergDocument[Tuple] = {
+    IcebergUtil.createTable(
+      IcebergCatalogInstance.getInstance(),
+      tableNamespace,
+      tableName,
+      icebergSchema,
+      overrideIfExists = true
+    )
+    new IcebergDocument[Tuple](tableNamespace, tableName, icebergSchema, 
serde, deserde)
+  }
+
+  private def tuple(id: Int): Tuple =
+    Tuple
+      .builder(amberSchema)
+      .add("id", AttributeType.INTEGER, Int.box(id))
+      .add("amount", AttributeType.LONG, Long.box(id.toLong * 100L))
+      .add("score", AttributeType.DOUBLE, Double.box(id.toDouble + 0.5))
+      .add("name", AttributeType.STRING, s"name-$id")
+      .add("ts", AttributeType.TIMESTAMP, new Timestamp(1_600_000_000_000L + 
id))
+      .build()
+
+  /** Write the given tuples through a single writer session (one committed 
file). */
+  private def write(doc: IcebergDocument[Tuple], tuples: Seq[Tuple]): Unit = {
+    val writer = doc.writer(UUID.randomUUID().toString)
+    writer.open()
+    tuples.foreach(writer.putOne)
+    writer.close()
+  }
+
+  "IcebergDocument" should "resolve the table location through getURI for an 
existing table" in {
+    val doc = newDocument()
+    // getURI loads the table metadata and wraps table.location() in 
URI.create.
+    // On a local `file:/` warehouse the location string carries the table 
name.
+    // On Windows the warehouse resolves to a raw `C:\...` path that URI.create
+    // rejects; in that case the method still executes its whole body (metadata
+    // load + location() + URI.create) before throwing, so it still pins the
+    // existing-table branch. The IllegalArgumentException escape is therefore
+    // allowed ONLY on Windows — elsewhere getURI must return a valid URI.
+    val isWindows = System.getProperty("os.name").toLowerCase.contains("win")
+    try {
+      doc.getURI.toString should include(doc.tableName)
+    } catch {
+      case _: IllegalArgumentException if isWindows => succeed
+    }
+  }
+
+  it should "throw NoSuchTableException from getURI when the table does not 
exist" in {
+    val doc = new IcebergDocument[Tuple](
+      tableNamespace,
+      freshTableName(),
+      icebergSchema,
+      serde,
+      deserde
+    )
+    intercept[NoSuchTableException] {
+      doc.getURI
+    }
+  }
+
+  it should "return count 0 for a freshly created, empty table" in {
+    val doc = newDocument()
+    doc.getCount shouldBe 0L
+    doc.get().hasNext shouldBe false
+  }
+
+  it should "return count 0 for a table that was never created" in {
+    val doc = new IcebergDocument[Tuple](
+      tableNamespace,
+      freshTableName(),
+      icebergSchema,
+      serde,
+      deserde
+    )
+    doc.getCount shouldBe 0L
+  }
+
+  it should "count and read back all written records" in {
+    val doc = newDocument()
+    val tuples = (0 until 10).map(tuple)
+    write(doc, tuples)
+
+    doc.getCount shouldBe 10L
+    val read = doc.get().toList
+    read should have size 10
+    read.map(_.getField[Int]("id")).toSet shouldBe (0 until 10).toSet
+  }
+
+  it should "read records in file-sequence order within a single committed 
file" in {
+    val doc = newDocument()
+    val tuples = (0 until 6).map(tuple)
+    write(doc, tuples)
+
+    doc.get().toList.map(_.getField[Int]("id")) shouldBe (0 until 6).toList
+  }
+
+  it should "return only the requested range via getRange" in {
+    val doc = newDocument()
+    write(doc, (0 until 10).map(tuple))
+
+    doc.getRange(2, 5).toList.map(_.getField[Int]("id")) shouldBe List(2, 3, 4)
+  }
+
+  it should "return records from an offset (inclusive) via getAfter" in {
+    val doc = newDocument()
+    write(doc, (0 until 5).map(tuple))
+
+    doc.getAfter(3).toList.map(_.getField[Int]("id")) shouldBe List(3, 4)
+  }
+
+  it should "project only the requested columns via getRange" in {
+    val doc = newDocument()
+    write(doc, (0 until 4).map(tuple))
+
+    val projected = doc.getRange(0, 4, Some(Seq("id"))).toList
+    projected.map(_.getField[Int]("id")) shouldBe List(0, 1, 2, 3)
+  }
+
+  it should "read incrementally across multiple committed files" in {
+    val doc = newDocument()
+    write(doc, (0 until 5).map(tuple))
+    write(doc, (5 until 10).map(tuple))
+
+    doc.getCount shouldBe 10L
+    doc.get().toList.map(_.getField[Int]("id")).toSet shouldBe (0 until 
10).toSet
+  }
+
+  it should "throw NoSuchElementException when advancing an exhausted 
iterator" in {
+    val doc = newDocument()
+    write(doc, Seq(tuple(1)))
+    val it = doc.get()
+    it.next()
+    it.hasNext shouldBe false
+    intercept[NoSuchElementException] {
+      it.next()
+    }
+  }
+
+  it should "compute per-field statistics for numeric, string and timestamp 
columns" in {
+    val doc = newDocument()
+    write(doc, (1 to 5).map(tuple))
+
+    val stats = doc.getTableStatistics
+    stats.keySet should contain allOf ("id", "amount", "score", "name", "ts")
+
+    // Numeric fields expose min/max plus a not-null count.
+    stats("id")("min").asInstanceOf[Double] shouldBe 1.0
+    stats("id")("max").asInstanceOf[Double] shouldBe 5.0
+    stats("id")("not_null_count").asInstanceOf[Long] shouldBe 5L
+    stats("amount")("min").asInstanceOf[Double] shouldBe 100.0
+    stats("amount")("max").asInstanceOf[Double] shouldBe 500.0
+    stats("score")("max").asInstanceOf[Double] shouldBe 5.5
+
+    // String field only carries the not-null count (no min/max keys).
+    stats("name")("not_null_count").asInstanceOf[Long] shouldBe 5L
+    stats("name").contains("min") shouldBe false
+
+    // Timestamp field exposes min/max as ISO local-date strings.
+    stats("ts") should contain key "min"
+    stats("ts") should contain key "max"
+  }
+
+  it should "throw NoSuchTableException from getTableStatistics when the table 
is missing" in {
+    val doc = new IcebergDocument[Tuple](
+      tableNamespace,
+      freshTableName(),
+      icebergSchema,
+      serde,
+      deserde
+    )
+    intercept[NoSuchTableException] {
+      doc.getTableStatistics
+    }
+  }
+
+  it should "report a positive total file size after writes and throw when 
missing" in {
+    val doc = newDocument()
+    write(doc, (0 until 8).map(tuple))
+    doc.getTotalFileSize should be > 0L
+
+    val missing = new IcebergDocument[Tuple](
+      tableNamespace,
+      freshTableName(),
+      icebergSchema,
+      serde,
+      deserde
+    )
+    intercept[NoSuchTableException] {
+      missing.getTotalFileSize
+    }
+  }
+
+  it should "expose an empty stream for an empty table via asInputStream" in {
+    val doc = newDocument()
+    val stream = doc.asInputStream()
+    try {
+      stream.readAllBytes() shouldBe empty
+    } finally {
+      stream.close()
+    }
+  }
+
+  it should "expose written rows as a non-empty ZIP archive via asInputStream" 
in {
+    val doc = newDocument()
+    write(doc, (0 until 3).map(tuple))
+
+    val stream = doc.asInputStream()
+    try {
+      val bytes = stream.readAllBytes()
+      bytes.length should be > 0
+      // ZIP local-file-header magic bytes: 0x50 0x4B ("PK").
+      bytes(0) shouldBe 0x50.toByte
+      bytes(1) shouldBe 0x4b.toByte
+    } finally {
+      stream.close()
+    }
+
+    // The archive should carry at least one .parquet entry.
+    val zip = new ZipInputStream(doc.asInputStream())
+    try {
+      val entry = zip.getNextEntry
+      entry should not be null
+      entry.getName should endWith(".parquet")
+    } finally {
+      zip.close()
+    }
+  }
+
+  it should "drop the table on clear so it no longer exists and counts as 
empty" in {
+    val doc = newDocument()
+    write(doc, (0 until 4).map(tuple))
+    doc.getCount shouldBe 4L
+
+    doc.clear()
+
+    IcebergCatalogInstance
+      .getInstance()
+      .tableExists(TableIdentifier.of(tableNamespace, doc.tableName)) shouldBe 
false
+    doc.getCount shouldBe 0L
+  }
+
+  it should "be a no-op when clearing a table that does not exist" in {
+    val doc = new IcebergDocument[Tuple](
+      tableNamespace,
+      freshTableName(),
+      icebergSchema,
+      serde,
+      deserde
+    )
+    noException should be thrownBy doc.clear()
+  }
+
+  it should "expose the constructed table identity and behave as a 
VirtualDocument" in {
+    val name = freshTableName()
+    val doc: VirtualDocument[Tuple] = newDocument(name)
+    doc.asInstanceOf[IcebergDocument[Tuple]].tableNamespace shouldBe 
tableNamespace
+    doc.asInstanceOf[IcebergDocument[Tuple]].tableName shouldBe name
+  }
+}

Reply via email to