Index: /trunk/BNC/src/upload/bncrawuploadcaster.cpp
===================================================================
--- /trunk/BNC/src/upload/bncrawuploadcaster.cpp	(revision 11059)
+++ /trunk/BNC/src/upload/bncrawuploadcaster.cpp	(revision 11060)
@@ -73,5 +73,5 @@
 
     if (_casters.contains(QString(staID))) {
-      _casters[QString(staID)]->setOutBuffer(data);
+      _casters[QString(staID)]->appendOutBuffer(data);
     }
 }
Index: /trunk/BNC/src/upload/bncuploadcaster.h
===================================================================
--- /trunk/BNC/src/upload/bncuploadcaster.h	(revision 11059)
+++ /trunk/BNC/src/upload/bncuploadcaster.h	(revision 11060)
@@ -24,4 +24,10 @@
     QMutexLocker locker(&_mutex);
     _outBuffer = outBuffer;
+  }
+  // Append data for streams forwarded chunk by chunk (raw upload): several
+  // chunks may arrive between two sends of the upload thread (every 0.1 s)
+  void appendOutBuffer(const QByteArray& data) {
+    QMutexLocker locker(&_mutex);
+    _outBuffer += data;
   }
 
Index: /trunk/BNC/tests/README.md
===================================================================
--- /trunk/BNC/tests/README.md	(revision 11059)
+++ /trunk/BNC/tests/README.md	(revision 11060)
@@ -25,4 +25,5 @@
 | `cases/<name>/expected/*.gz` | normalised reference outputs |
 | `data/*.raw` | short raw captures (BNC `rawOutFile` format) replayed via `--file` |
+| `tools/fakecaster.py` | local Ntrip caster recording an upload as a BNC raw file (case key `fakecaster`) |
 | `tools/bncraw.py` | `info` (streams, time span, RTCM3 message types) and `slice` for raw captures |
 | `work/` | scratch output, not versioned |
@@ -59,4 +60,10 @@
   (SSRA00BKG1, SSRA00CNE1, BCEP00BKG0, FFMJ00DEU0), upload to the caster
   disabled, SP3/CLK written to files.
+* Upload (`upload_*`; 16, 19, 20) from `data/upload_rtnet_5min.raw` (RTNET engine
+  output + BCEP00BKG0): BNC uploads to a local fake caster
+  (`tools/fakecaster.py`, Ntrip 1/2, chunked), and a `post` step decodes the
+  received stream again: `upload_rtnet` (IGS-SSR encoding, SP3/CLK/BIA files,
+  decoded `.ssr`), `upload_eph` (RTCM3 ephemerides to RINEX nav) and
+  `upload_raw` (raw forwarding to RINEX nav).
 * SSR decoding (`ssr_*`): 3 min slices of real streams replayed through
   `03_BrdcCorr.bnc`. They compare the `.ssr` files and the `BNC_DEBUG_SSR`
Index: /trunk/BNC/tests/run_tests.py
===================================================================
--- /trunk/BNC/tests/run_tests.py	(revision 11059)
+++ /trunk/BNC/tests/run_tests.py	(revision 11060)
@@ -24,4 +24,11 @@
                  debug_trace  true for BNC_DEBUG_SSR stderr traces: reported as
                             SKIP instead of FAIL if the build has no trace output
+  fakecaster   optional local Ntrip caster for an upload during the run:
+               {"port", "out" (raw file, relative to the work dir), "sta",
+                "time" (epoch stamped on the raw file), "timeout"}
+  post         optional list of further BNC runs after the main run, each
+               {"dir" (subdirectory of the work dir), "config", "raw"
+                (relative to the work dir), "args", "exit_code", "timeout"};
+               outputs may refer to their files as "<dir>/Output/..."
 
 BNC runs in tests/work/<name>/, which contains an "Input" symlink to
@@ -146,4 +153,28 @@
 
 
+def run_bnc(bnc, workdir, spec, rawBase):
+    """Run BNC once in workdir (created with Input link and Output dir); spec
+    holds config, optional raw (relative to tests/, or to rawBase if given),
+    args and timeout. Returns the exit code or None on timeout."""
+    os.makedirs(os.path.join(workdir, "Output"), exist_ok=True)
+    if not os.path.exists(os.path.join(workdir, "Input")):
+        os.symlink(os.path.join(ROOT, "Example_Configs", "Input"), os.path.join(workdir, "Input"))
+    cmd = [bnc, "--nw", "--platform", "offscreen", "--conf", resolve(ROOT, spec["config"])]
+    if spec.get("raw"):
+        cmd += ["--file", resolve(rawBase or TESTS, spec["raw"])]
+    cmd += spec.get("args", [])
+    env = dict(os.environ, QT_QPA_PLATFORM="offscreen", LC_ALL="C", TZ="UTC")
+    with open(os.path.join(workdir, "stdout.txt"), "wb") as out, \
+         open(os.path.join(workdir, "stderr.txt"), "wb") as err:
+        try:
+            code = subprocess.run(cmd, cwd=workdir, stdout=out, stderr=err, env=env,
+                                  timeout=spec.get("timeout", 600)).returncode
+        except subprocess.TimeoutExpired:
+            code = None
+    with open(os.path.join(workdir, "command.txt"), "w") as f:
+        f.write(" ".join(cmd) + "\n")
+    return code
+
+
 def run_case(name, bnc, update):
     res = Result(name)
@@ -153,26 +184,26 @@
 
     shutil.rmtree(work, ignore_errors=True)
-    os.makedirs(os.path.join(work, "Output"))
-    os.symlink(os.path.join(ROOT, "Example_Configs", "Input"), os.path.join(work, "Input"))
-
-    cmd = [bnc, "--nw", "--platform", "offscreen",
-           "--conf", resolve(ROOT, case["config"])]
-    if case.get("raw"):
-        cmd += ["--file", resolve(TESTS, case["raw"])]
-    cmd += case.get("args", [])
-
-    env = dict(os.environ, QT_QPA_PLATFORM="offscreen", LC_ALL="C", TZ="UTC")
     t0 = time.time()
-    with open(os.path.join(work, "stdout.txt"), "wb") as out, \
-         open(os.path.join(work, "stderr.txt"), "wb") as err:
+
+    # Optional local Ntrip caster receiving an upload (see tools/fakecaster.py)
+    fc = case.get("fakecaster")
+    fcProc = None
+    if fc:
+        os.makedirs(os.path.join(work, "Output"))
+        fcProc = subprocess.Popen(
+            [sys.executable, os.path.join(TESTS, "tools", "fakecaster.py"),
+             "--port", str(fc["port"]), "--out", os.path.join(work, fc["out"]),
+             "--sta", fc.get("sta", "CLOCK"), "--time", fc["time"],
+             "--timeout", str(fc.get("timeout", 10))],
+            stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+        fcProc.stdout.readline()  # "listening"
+
+    code = run_bnc(bnc, work, case, "")
+    if fcProc:
         try:
-            proc = subprocess.run(cmd, cwd=work, stdout=out, stderr=err, env=env,
-                                  timeout=case.get("timeout", 600))
-            code = proc.returncode
+            fcProc.communicate(timeout=fc.get("timeout", 10) + 60)
         except subprocess.TimeoutExpired:
-            code = None
-    res.seconds = time.time() - t0
-    with open(os.path.join(work, "command.txt"), "w") as f:
-        f.write(" ".join(cmd) + "\n")
+            fcProc.kill()
+            res.fail("fake caster did not finish")
 
     if code is None:
@@ -181,4 +212,12 @@
     if code != case.get("exit_code", 0):
         res.fail("exit code %d, expected %d" % (code, case.get("exit_code", 0)))
+
+    # Optional further BNC runs, e.g. decoding the uploaded stream; each runs
+    # in its own subdirectory of the work directory, raw relative to work/
+    for post in case.get("post", []):
+        pcode = run_bnc(bnc, os.path.join(work, post["dir"]), post, work)
+        if pcode is None or pcode != post.get("exit_code", 0):
+            res.fail("post step %s: exit code %s" % (post["dir"], pcode))
+    res.seconds = time.time() - t0
 
     # Collect (reference name, actual file, spec) triples
