feat: implement --delay-updates receiver staging and publication
CI / lint (pull_request) Failing after 22s
CI / build-and-test (pull_request) Skipped
CI / sanitizers (address) (pull_request) Skipped
CI / sanitizers (undefined) (pull_request) Skipped
CI / fuzz-build (pull_request) Skipped
CI / coverage (pull_request) Skipped
CI / valgrind (pull_request) Skipped
CI / lint (pull_request) Failing after 22s
CI / build-and-test (pull_request) Skipped
CI / sanitizers (address) (pull_request) Skipped
CI / sanitizers (undefined) (pull_request) Skipped
CI / fuzz-build (pull_request) Skipped
CI / coverage (pull_request) Skipped
CI / valgrind (pull_request) Skipped
Stage every successfully written file under a private 0700 .fastsync-stage directory inside the receive root and atomically publish all staged files only after the whole protocol stream (manifest/delete handling included) has completed, immediately before the success/outcome frame. On any abort/error before publication nothing is installed and staging is removed; a publish failure aborts the transfer with best-effort cleanup of the remainder (already-published files are not rolled back). Crash leftovers are wiped when the next delayed transfer starts. Wire: new delay_updates config flag (selection-options block), protocol version bumped to 2.6.0, client/server validation rejects --inplace. CLI/usage/validation updated. Works in single-threaded and -m modes (exactly one write_thread stages files; the staged-file registry is mutex-protected; publication runs once after both threads join). --existing/--ignore-existing/--update decide against the final destination at stage time; --backup is deferred to publication. remove_source_files outcomes are only sent after publication so skipped/unpublished sources are never deleted. Default (no flag) behavior is unchanged. Tests: config wire round-trip, CLI parse, --inplace rejection, new test_delay_updates unit suite (27 suites total), and integration TestDelayUpdates covering single/-m parity, incremental reruns, remove source files, receiver-skip ordering, and a deterministic publish-failure abort path.
This commit is contained in:
@@ -1405,3 +1405,154 @@ class TestLogFileFormat:
|
||||
expected = {f"{os.path.join(source, rel)} {len(data)}" for rel, data in files.items()}
|
||||
for line in expected:
|
||||
assert line in content, f"log file (-m) missing {line!r}"
|
||||
|
||||
|
||||
class TestDelayUpdates:
|
||||
"""--delay-updates stages every updated file under a private 0700 staging
|
||||
directory inside the receive root and atomically publishes all of them only
|
||||
after the whole transfer succeeds."""
|
||||
|
||||
STAGING = ".fastsync-stage"
|
||||
|
||||
def _make_source(self, name):
|
||||
source = os.path.join(TEST_DATA_DIR, name)
|
||||
clean_dir(source)
|
||||
entries = {
|
||||
"top.txt": b"top level\n",
|
||||
"sub/deep.txt": b"deeply nested file\n",
|
||||
"sub/another.txt": b"another nested file\n" * 20,
|
||||
"binary.bin": bytes(range(256)) * 4,
|
||||
}
|
||||
for rel, content in entries.items():
|
||||
full = os.path.join(source, rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(content)
|
||||
return source
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_delay_updates_matches_plain_transfer(self, shared_server, mt):
|
||||
source = self._make_source("delay_match_src")
|
||||
plain_dest = os.path.join(TEST_DATA_DIR, "delay_match_plain_dst")
|
||||
delay_dest = os.path.join(TEST_DATA_DIR, "delay_match_delay_dst")
|
||||
clean_dir(plain_dest)
|
||||
clean_dir(delay_dest)
|
||||
|
||||
result, _ = run_client(source, plain_dest, port=shared_server.port)
|
||||
assert result.returncode == 0, f"plain sync failed: {result.stderr[:200]}"
|
||||
flags = ["--delay-updates"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, delay_dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"delay-updates sync failed: {result.stderr[:200]}"
|
||||
|
||||
plain_received = get_dest_received_dir(plain_dest, source)
|
||||
delay_received = get_dest_received_dir(delay_dest, source)
|
||||
mismatches, missing = verify_transfer(source, delay_received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
for root, _dirs, files in os.walk(delay_received):
|
||||
for name in files:
|
||||
rel = os.path.relpath(os.path.join(root, name), delay_received)
|
||||
assert filecmp.cmp(os.path.join(plain_received, rel),
|
||||
os.path.join(delay_received, rel), shallow=False), rel
|
||||
assert not os.path.isdir(os.path.join(delay_dest, self.STAGING)), \
|
||||
"staging directory left behind after a successful delayed transfer"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_delay_updates_incremental_rerun_no_leftovers(self, shared_server, mt):
|
||||
source = self._make_source("delay_rerun_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "delay_rerun_dst")
|
||||
clean_dir(dest)
|
||||
flags = ["--delay-updates", "-M", "--incremental"] + (["-m"] if mt else [])
|
||||
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"first delayed sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
mismatches, missing = verify_transfer(source, received)
|
||||
assert not missing and not mismatches
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING))
|
||||
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"second delayed sync failed: {result.stderr[:200]}"
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING)), \
|
||||
"fully-skipped delayed run left a staging directory"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_remove_source_files_with_delay_updates(self, shared_server, mt):
|
||||
source = self._make_source("delay_rsf_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "delay_rsf_dst")
|
||||
clean_dir(dest)
|
||||
flags = ["--remove-source-files", "--delay-updates"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"delayed remove-source sync failed: {result.stderr[:200]}"
|
||||
|
||||
# Sources are removed only after the receiver published every file.
|
||||
for root, _dirs, files in os.walk(source):
|
||||
assert files == [], f"source files survived delayed remove-source-files: {files}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert os.path.isfile(os.path.join(received, "top.txt"))
|
||||
assert os.path.isfile(os.path.join(received, "sub", "deep.txt"))
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING))
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_abort_publish_failure_installs_nothing(self, shared_server, mt):
|
||||
"""A deterministic publication failure must fail the transfer, leave no
|
||||
file in the final destination, and clean up the staging area. A plain
|
||||
file is planted where the final destination directory must be created,
|
||||
so the very first stage->publish rename fails (mkdir is impossible on
|
||||
top of a file even for root). Exercised in both single and -m modes so
|
||||
the multithreaded publish-once ordering is covered."""
|
||||
source = self._make_source("delay_abort_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "delay_abort_dst")
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
os.makedirs(os.path.dirname(received), exist_ok=True)
|
||||
with open(received, "wb") as fh:
|
||||
fh.write(b"blocks the destination directory")
|
||||
flags = ["--delay-updates"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode != 0, "blocked destination publish did not fail"
|
||||
for root, _dirs, files in os.walk(source):
|
||||
for name in files:
|
||||
rel = os.path.relpath(os.path.join(root, name), source)
|
||||
assert not os.path.exists(os.path.join(received, rel)), \
|
||||
f"file appeared at final destination despite failed publish: {rel}"
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING)), \
|
||||
"staging leftovers after a failed publish"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_remove_source_files_keeps_receiver_skipped_source(self, shared_server, mt):
|
||||
"""With --delay-updates + --ignore-existing a receiver-skipped source
|
||||
must survive (its outcome is sent only after publication) while a
|
||||
freshly delivered file is published and its source removed."""
|
||||
source = os.path.join(TEST_DATA_DIR, "delay_rsf_skip_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "delay_rsf_skip_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
with open(os.path.join(source, "keep.txt"), "wb") as fh:
|
||||
fh.write(b"existing on dest")
|
||||
result, _ = run_client(source, dest, port=shared_server.port)
|
||||
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
|
||||
|
||||
with open(os.path.join(source, "keep.txt"), "wb") as fh:
|
||||
fh.write(b"changed on source")
|
||||
with open(os.path.join(source, "deliver.txt"), "wb") as fh:
|
||||
fh.write(b"new file")
|
||||
flags = ["--remove-source-files", "--ignore-existing", "--delay-updates"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"delayed skip sync failed: {result.stderr[:200]}"
|
||||
# keep.txt already existed at the destination: receiver skip -> source stays.
|
||||
assert os.path.isfile(os.path.join(source, "keep.txt")), \
|
||||
"receiver-skipped source was removed despite --ignore-existing"
|
||||
# deliver.txt was new: staged, published, and its source removed.
|
||||
assert not os.path.isfile(os.path.join(source, "deliver.txt")), \
|
||||
"published source was not removed"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING))
|
||||
|
||||
def test_delay_updates_rejects_inplace(self):
|
||||
source = self._make_source("delay_inplace_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "delay_inplace_dst")
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, flags=["--delay-updates", "--inplace"])
|
||||
assert result.returncode != 0, "--inplace with --delay-updates was accepted"
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING))
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "test_compression.h"
|
||||
#include "test_config.h"
|
||||
#include "test_data.h"
|
||||
#include "test_delay_updates.h"
|
||||
#include "test_delta.h"
|
||||
#include "test_file.h"
|
||||
#include "test_file_sendfile.h"
|
||||
@@ -51,6 +52,7 @@ int main() {
|
||||
RUN_TEST(test_metadata);
|
||||
RUN_TEST(test_glob);
|
||||
RUN_TEST(test_file);
|
||||
RUN_TEST(test_delay_updates);
|
||||
RUN_TEST(test_file_sendfile);
|
||||
RUN_TEST(test_multiprocessing);
|
||||
RUN_TEST(test_log);
|
||||
|
||||
@@ -1265,6 +1265,28 @@ static void test_parse_args_log_file_format() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --delay-updates is a plain boolean receiver option. */
|
||||
static void test_parse_args_delay_updates() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--delay-updates", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->delay_updates);
|
||||
EXPECT_EQ_INT(positional_count, 2);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* rsync rejects --delay-updates with --inplace; FastSync must too. */
|
||||
static void test_validate_config_delay_updates_rejects_inplace() {
|
||||
Config* cfg = valid_client_config();
|
||||
cfg->delay_updates = true;
|
||||
cfg->inplace = true;
|
||||
EXPECT_FALSE(validate_config(cfg));
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
void test_client_cli() {
|
||||
test_validate_config_required_paths();
|
||||
test_validate_config_incompatible_options();
|
||||
@@ -1344,4 +1366,6 @@ void test_client_cli() {
|
||||
test_parse_args_checksum_choice_aliases();
|
||||
test_parse_args_checksum_choice_requires_value();
|
||||
test_parse_args_temp_dir();
|
||||
test_parse_args_delay_updates();
|
||||
test_validate_config_delay_updates_rejects_inplace();
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ static void test_config_send_receive() {
|
||||
send_cfg->modify_window = 4;
|
||||
send_cfg->existing = true;
|
||||
send_cfg->ignore_existing = true;
|
||||
send_cfg->delay_updates = true;
|
||||
send_cfg->skip_compress_set = true;
|
||||
send_cfg->skip_compress_count = 1;
|
||||
send_cfg->skip_compress_suffixes = calloc(1, sizeof(char*));
|
||||
@@ -191,6 +192,8 @@ static void test_config_send_receive() {
|
||||
ok = false;
|
||||
if (!recv_cfg->ignore_existing)
|
||||
ok = false;
|
||||
if (!recv_cfg->delay_updates)
|
||||
ok = false;
|
||||
if (!recv_cfg->skip_compress_set || recv_cfg->skip_compress_count != 1 ||
|
||||
strcmp(recv_cfg->skip_compress_suffixes[0], ".zip") != 0)
|
||||
ok = false;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#include "test_delay_updates.h"
|
||||
#include "config.h"
|
||||
#include "delay_updates.h"
|
||||
#include "file.h"
|
||||
#include "file_receive.h"
|
||||
#include "test_utils.h"
|
||||
#include "utils.h"
|
||||
#include <dirent.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Recursively remove a test tree (never follows symlinks). */
|
||||
static void remove_tree(const char* path) {
|
||||
struct stat st;
|
||||
if (lstat(path, &st) != 0)
|
||||
return;
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
DIR* dir = opendir(path);
|
||||
if (!dir)
|
||||
return;
|
||||
const struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
|
||||
continue;
|
||||
char* child = path_cat(path, entry->d_name);
|
||||
if (child) {
|
||||
remove_tree(child);
|
||||
free(child);
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
rmdir(path);
|
||||
} else {
|
||||
unlink(path);
|
||||
}
|
||||
}
|
||||
|
||||
/* Build a File that carries `content`. */
|
||||
static File* make_file(const char* path, const char* content) {
|
||||
File* f = file_create(path);
|
||||
if (!f)
|
||||
return NULL;
|
||||
f->data->data = malloc(strlen(content));
|
||||
if (!f->data->data) {
|
||||
file_destroy(f);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(f->data->data, content, strlen(content));
|
||||
f->data->size = strlen(content);
|
||||
return f;
|
||||
}
|
||||
|
||||
static char* read_all(const char* path) {
|
||||
FILE* fp = fopen(path, "rb");
|
||||
if (!fp)
|
||||
return NULL;
|
||||
char buf[256] = {0};
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
|
||||
fclose(fp);
|
||||
char* out = malloc(n + 1);
|
||||
if (!out)
|
||||
return NULL;
|
||||
memcpy(out, buf, n);
|
||||
out[n] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
static void test_delay_updates_no_final_before_publish() {
|
||||
const char* root = "test_delay_tmp";
|
||||
remove_tree(root);
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->delay_updates = true;
|
||||
|
||||
File* f = make_file("sub/file.txt", "staged payload");
|
||||
EXPECT_NOT_NULL(f);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (!cfg || !f)
|
||||
goto out;
|
||||
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
|
||||
EXPECT_NOT_NULL(cfg->delay_context);
|
||||
|
||||
const char* final_path = "test_delay_tmp/sub/file.txt";
|
||||
/* Before publication the final destination must not contain the file. */
|
||||
EXPECT_FALSE(file_path_exists_secure(final_path));
|
||||
/* The complete staged copy must live inside the staging tree. */
|
||||
char* staged = path_cat("test_delay_tmp/.fastsync-stage", "/sub/file.txt");
|
||||
EXPECT_NOT_NULL(staged);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (staged) {
|
||||
char* content = read_all(staged);
|
||||
EXPECT_NOT_NULL(content);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (content) {
|
||||
EXPECT_EQ_STR(content, "staged payload");
|
||||
free(content);
|
||||
}
|
||||
free(staged);
|
||||
}
|
||||
|
||||
out:
|
||||
file_destroy(f);
|
||||
config_delete(cfg);
|
||||
remove_tree(root);
|
||||
}
|
||||
|
||||
static void test_delay_updates_publish_installs_files() {
|
||||
const char* root = "test_delay_pub_tmp";
|
||||
remove_tree(root);
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->delay_updates = true;
|
||||
|
||||
File* f = make_file("sub/file.txt", "published payload");
|
||||
EXPECT_NOT_NULL(f);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (!cfg || !f)
|
||||
goto out;
|
||||
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
|
||||
const char* final_path = "test_delay_pub_tmp/sub/file.txt";
|
||||
EXPECT_FALSE(file_path_exists_secure(final_path));
|
||||
|
||||
EXPECT_TRUE(delay_updates_publish(cfg->delay_context, cfg));
|
||||
/* After a successful publish the file is installed and staging is gone. */
|
||||
char* content = read_all(final_path);
|
||||
EXPECT_NOT_NULL(content);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (content) {
|
||||
EXPECT_EQ_STR(content, "published payload");
|
||||
free(content);
|
||||
}
|
||||
EXPECT_FALSE(file_path_exists_secure("test_delay_pub_tmp/.fastsync-stage"));
|
||||
|
||||
out:
|
||||
file_destroy(f);
|
||||
config_delete(cfg);
|
||||
remove_tree(root);
|
||||
}
|
||||
|
||||
/* The staged tree is cleaned on the error/abort path and final files that were
|
||||
never published do not appear at the destination. */
|
||||
static void test_delay_updates_cleanup_removes_staged() {
|
||||
const char* root = "test_delay_clean_tmp";
|
||||
remove_tree(root);
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->delay_updates = true;
|
||||
|
||||
File* f = make_file("sub/file.txt", "never installed");
|
||||
EXPECT_NOT_NULL(f);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (!cfg || !f)
|
||||
goto out;
|
||||
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
|
||||
EXPECT_TRUE(file_path_exists_secure("test_delay_clean_tmp/.fastsync-stage/sub/file.txt"));
|
||||
|
||||
delay_updates_cleanup(cfg->delay_context);
|
||||
EXPECT_FALSE(file_path_exists_secure("test_delay_clean_tmp/.fastsync-stage"));
|
||||
EXPECT_FALSE(file_path_exists_secure("test_delay_clean_tmp/sub/file.txt"));
|
||||
|
||||
out:
|
||||
file_destroy(f);
|
||||
config_delete(cfg);
|
||||
remove_tree(root);
|
||||
}
|
||||
|
||||
/* With --backup the previous version is only moved aside at publication. */
|
||||
static void test_delay_updates_backup_deferred_to_publish() {
|
||||
const char* root = "test_delay_bak_tmp";
|
||||
remove_tree(root);
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->delay_updates = true;
|
||||
cfg->backup = true;
|
||||
|
||||
EXPECT_TRUE(file_write_to_disk("test_delay_bak_tmp/file.txt", "AAAA", 4, false, false));
|
||||
|
||||
File* f = make_file("file.txt", "BBBB");
|
||||
EXPECT_NOT_NULL(f);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (!cfg || !f)
|
||||
goto out;
|
||||
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
|
||||
/* Stage time must not touch the final file or create the backup yet. */
|
||||
char* before = read_all("test_delay_bak_tmp/file.txt");
|
||||
EXPECT_NOT_NULL(before);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (before) {
|
||||
EXPECT_EQ_STR(before, "AAAA");
|
||||
free(before);
|
||||
}
|
||||
EXPECT_FALSE(file_path_exists_secure("test_delay_bak_tmp/file.txt~"));
|
||||
|
||||
EXPECT_TRUE(delay_updates_publish(cfg->delay_context, cfg));
|
||||
char* after = read_all("test_delay_bak_tmp/file.txt");
|
||||
char* backup = read_all("test_delay_bak_tmp/file.txt~");
|
||||
EXPECT_NOT_NULL(after);
|
||||
EXPECT_NOT_NULL(backup);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (after) {
|
||||
EXPECT_EQ_STR(after, "BBBB");
|
||||
free(after);
|
||||
}
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (backup) {
|
||||
EXPECT_EQ_STR(backup, "AAAA");
|
||||
free(backup);
|
||||
}
|
||||
|
||||
out:
|
||||
file_destroy(f);
|
||||
config_delete(cfg);
|
||||
remove_tree(root);
|
||||
}
|
||||
|
||||
/* Skip/update policy checks run against the final path at stage time, matching
|
||||
what an immediate run would decide. */
|
||||
static void test_delay_updates_skip_semantics() {
|
||||
const char* root = "test_delay_skip_tmp";
|
||||
remove_tree(root);
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->delay_updates = true;
|
||||
|
||||
/* --existing: final destination missing -> skipped, nothing staged. */
|
||||
File* missing = make_file("missing.txt", "new");
|
||||
EXPECT_NOT_NULL(missing);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (!cfg || !missing)
|
||||
goto out;
|
||||
cfg->existing = true;
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, missing, cfg), FILE_SAVE_SKIPPED);
|
||||
cfg->existing = false;
|
||||
|
||||
/* --ignore-existing: final destination present -> skipped. */
|
||||
EXPECT_TRUE(file_write_to_disk("test_delay_skip_tmp/existing.txt", "old", 3, false, false));
|
||||
File* present = make_file("existing.txt", "new");
|
||||
EXPECT_NOT_NULL(present);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (!present)
|
||||
goto out;
|
||||
cfg->ignore_existing = true;
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, present, cfg), FILE_SAVE_SKIPPED);
|
||||
cfg->ignore_existing = false;
|
||||
|
||||
/* Without a skip flag the file is staged and later published. */
|
||||
File* fresh = make_file("fresh.txt", "content");
|
||||
EXPECT_NOT_NULL(fresh);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (!fresh)
|
||||
goto out;
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, fresh, cfg), FILE_SAVE_WRITTEN);
|
||||
EXPECT_TRUE(delay_updates_publish(cfg->delay_context, cfg));
|
||||
char* content = read_all("test_delay_skip_tmp/fresh.txt");
|
||||
EXPECT_NOT_NULL(content);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (content) {
|
||||
EXPECT_EQ_STR(content, "content");
|
||||
free(content);
|
||||
}
|
||||
|
||||
out:
|
||||
file_destroy(missing);
|
||||
file_destroy(present);
|
||||
file_destroy(fresh);
|
||||
config_delete(cfg);
|
||||
remove_tree(root);
|
||||
}
|
||||
|
||||
void test_delay_updates() {
|
||||
test_delay_updates_no_final_before_publish();
|
||||
test_delay_updates_publish_installs_files();
|
||||
test_delay_updates_cleanup_removes_staged();
|
||||
test_delay_updates_backup_deferred_to_publish();
|
||||
test_delay_updates_skip_semantics();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef TEST_DELAY_UPDATES_H
|
||||
#define TEST_DELAY_UPDATES_H
|
||||
|
||||
void test_delay_updates(void);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user