Files
nocal/tests/sync_cache_tests.cpp
Bernardo Magri 98e07e287e feat(sync): reconcile secondary Graph calendars
Read documented Graph v1 calendarView pages for secondary calendars and publish each finite window only after the complete bounded listing validates.

Add schema-v2 per-window event membership so moved-out occurrences are evicted without being mislabeled as remote tombstones, while overlapping windows retain shared instances.

Verified: 18/18 fresh normal, 18/18 -Werror, and 18/18 ASan/UBSan tests.
2026-07-18 13:05:21 +01:00

1104 lines
46 KiB
C++

#include "nocal/storage/sync_cache.hpp"
#include <sqlite3.h>
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <exception>
#include <filesystem>
#include <fstream>
#include <functional>
#include <iostream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
#if !defined(_WIN32)
#include <sys/stat.h>
#include <unistd.h>
#endif
namespace {
using nocal::storage::CacheSnapshot;
using nocal::storage::CachedAccount;
using nocal::storage::CachedCalendar;
using nocal::storage::CachedEvent;
using nocal::storage::CachedEventInstance;
using nocal::storage::CompleteWindow;
using nocal::storage::PullPage;
using nocal::storage::SyncCache;
using nocal::storage::SyncCheckpoint;
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string{message});
}
}
template <typename Function>
void require_throws(Function&& function, std::string_view message) {
try {
function();
} catch (const std::exception&) {
return;
}
throw std::runtime_error(std::string{message});
}
class TempDirectory {
public:
TempDirectory() {
#if !defined(_WIN32)
std::string pattern =
(std::filesystem::temp_directory_path() / "nocal-sync-cache-tests.XXXXXX").string();
std::vector<char> writable(pattern.begin(), pattern.end());
writable.push_back('\0');
const char* created = ::mkdtemp(writable.data());
if (created == nullptr) {
throw std::runtime_error("mkdtemp failed");
}
path_ = created;
#else
path_ = std::filesystem::temp_directory_path() / "nocal-sync-cache-tests";
std::filesystem::remove_all(path_);
std::filesystem::create_directories(path_);
#endif
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
class SqliteConnection {
public:
explicit SqliteConnection(const std::filesystem::path& path) {
const int result = sqlite3_open_v2(
path.c_str(), &database_, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr);
if (result != SQLITE_OK) {
const std::string message = database_ == nullptr ? "sqlite open failed"
: sqlite3_errmsg(database_);
if (database_ != nullptr) {
sqlite3_close(database_);
database_ = nullptr;
}
throw std::runtime_error(message);
}
}
SqliteConnection(const SqliteConnection&) = delete;
SqliteConnection& operator=(const SqliteConnection&) = delete;
~SqliteConnection() {
if (database_ != nullptr) {
sqlite3_close(database_);
}
}
void execute(std::string_view sql) {
char* error = nullptr;
const std::string statement{sql};
const int result = sqlite3_exec(database_, statement.c_str(), nullptr, nullptr, &error);
if (result != SQLITE_OK) {
const std::string message = error == nullptr ? sqlite3_errmsg(database_) : error;
sqlite3_free(error);
throw std::runtime_error(message);
}
}
[[nodiscard]] std::string scalar_text(std::string_view sql) const {
sqlite3_stmt* statement = nullptr;
const std::string query{sql};
if (sqlite3_prepare_v2(database_, query.c_str(), -1, &statement, nullptr) != SQLITE_OK) {
throw std::runtime_error(sqlite3_errmsg(database_));
}
struct Finalize {
sqlite3_stmt* statement;
~Finalize() { sqlite3_finalize(statement); }
} finalize{statement};
if (sqlite3_step(statement) != SQLITE_ROW) {
throw std::runtime_error(sqlite3_errmsg(database_));
}
const unsigned char* value = sqlite3_column_text(statement, 0);
return value == nullptr ? std::string{} : reinterpret_cast<const char*>(value);
}
[[nodiscard]] int scalar_int(std::string_view sql) const {
sqlite3_stmt* statement = nullptr;
const std::string query{sql};
if (sqlite3_prepare_v2(database_, query.c_str(), -1, &statement, nullptr) != SQLITE_OK) {
throw std::runtime_error(sqlite3_errmsg(database_));
}
struct Finalize {
sqlite3_stmt* statement;
~Finalize() { sqlite3_finalize(statement); }
} finalize{statement};
if (sqlite3_step(statement) != SQLITE_ROW) {
throw std::runtime_error(sqlite3_errmsg(database_));
}
return sqlite3_column_int(statement, 0);
}
[[nodiscard]] std::vector<std::string> first_column(std::string_view sql) const {
sqlite3_stmt* statement = nullptr;
const std::string query{sql};
if (sqlite3_prepare_v2(database_, query.c_str(), -1, &statement, nullptr) != SQLITE_OK) {
throw std::runtime_error(sqlite3_errmsg(database_));
}
struct Finalize {
sqlite3_stmt* statement;
~Finalize() { sqlite3_finalize(statement); }
} finalize{statement};
std::vector<std::string> values;
int result = SQLITE_ROW;
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
const unsigned char* value = sqlite3_column_text(statement, 0);
values.emplace_back(value == nullptr ? "" : reinterpret_cast<const char*>(value));
}
if (result != SQLITE_DONE) {
throw std::runtime_error(sqlite3_errmsg(database_));
}
return values;
}
private:
sqlite3* database_{nullptr};
};
CachedAccount account(std::string id = "account-a") {
return CachedAccount{std::move(id), "microsoft-graph", "subject-a", "Work account"};
}
CachedCalendar calendar(
std::string id = "calendar-a", std::string account_id = "account-a") {
return CachedCalendar{std::move(id), std::move(account_id), "remote-calendar-a", "Calendar A",
"blue", false, true, R"({"calendar":"a"})"};
}
CachedEvent event(std::string id = "event-a", std::string calendar_id = "calendar-a") {
return CachedEvent{std::move(id), std::move(calendar_id), "remote-event-a", "uid-a", "etag-a",
"change-a", R"({"event":"a"})", false};
}
CachedEventInstance instance(
std::string id = "instance-a", std::string event_id = "event-a") {
return CachedEventInstance{std::move(id), std::move(event_id), 1'000'000, 2'000'000, false,
"Event A", "Room", "Description", "Europe/London"};
}
SyncCheckpoint checkpoint(std::string cursor = "cursor-a", std::string calendar_id = "calendar-a",
std::string window_start = "opaque-start", std::string window_end = "opaque-end") {
return SyncCheckpoint{std::move(calendar_id), std::move(window_start), std::move(window_end),
std::move(cursor), false};
}
PullPage page(CachedEvent changed_event = event(), CachedEventInstance changed_instance = instance(),
SyncCheckpoint next = checkpoint()) {
return PullPage{{std::move(changed_event)}, {std::move(changed_instance)}, std::move(next)};
}
CompleteWindow complete_window(std::vector<CachedEvent> events = {event()},
std::vector<CachedEventInstance> instances = {instance()},
SyncCheckpoint complete_checkpoint = checkpoint()) {
complete_checkpoint.complete = true;
return {std::move(events), std::move(instances), std::move(complete_checkpoint)};
}
void seed_calendar(SyncCache& cache, const CachedAccount& cached_account = account(),
const CachedCalendar& cached_calendar = calendar()) {
cache.upsert_account(cached_account);
const std::vector calendars{cached_calendar};
cache.replace_calendars_after_complete_listing(cached_account.id, calendars);
}
[[nodiscard]] bool contains(const std::vector<std::string>& values, std::string_view value) {
return std::ranges::find(values, value) != values.end();
}
#if !defined(_WIN32)
void require_private_file(const std::filesystem::path& path, std::string_view description) {
struct stat metadata {};
require(::stat(path.c_str(), &metadata) == 0,
std::string{"could not stat "}.append(description));
require(S_ISREG(metadata.st_mode), std::string{description}.append(" is not a regular file"));
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
std::string{description}.append(" grants group or other permissions"));
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
std::string{description}.append(" is not owner-readable and writable"));
}
#endif
void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root) {
const auto path = root / "fresh.db";
SyncCache cache{path};
require(cache.path() == path, "cache did not retain its path");
require(std::filesystem::is_regular_file(path), "fresh cache database was not created");
SqliteConnection inspection{path};
require(inspection.scalar_text("PRAGMA journal_mode") == "wal", "cache is not in WAL mode");
require(inspection.scalar_int("PRAGMA user_version") == SyncCache::current_schema_version,
"fresh cache schema version is incorrect");
const auto tables = inspection.first_column(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");
for (const std::string_view required : {"accounts", "calendars", "events",
"event_instances", "event_window_membership", "sync_state", "outbox",
"conflicts"}) {
require(contains(tables, required), std::string{"missing cache table: "}.append(required));
}
for (const std::string_view child : {"calendars", "events", "event_instances",
"event_window_membership", "sync_state"}) {
require(inspection.scalar_int(
std::string{"SELECT COUNT(*) FROM pragma_foreign_key_list('"}
.append(child)
.append("')"))
> 0,
std::string{"missing foreign key on "}.append(child));
}
require(inspection.scalar_int(
"SELECT COUNT(*) FROM pragma_index_list('event_window_membership') "
"WHERE name='event_window_membership_event_id_idx'")
== 1,
"complete-window event lookup index is missing");
const auto columns = inspection.first_column(
"SELECT lower(m.name || '.' || p.name) FROM sqlite_master AS m "
"JOIN pragma_table_info(m.name) AS p WHERE m.type='table'");
for (const auto& column : columns) {
require(column.find("token") == std::string::npos
&& column.find("secret") == std::string::npos
&& column.find("credential") == std::string::npos,
"cache schema contains a token, secret, or credential column");
}
#if !defined(_WIN32)
require_private_file(path, "cache database");
for (const auto suffix : {"-wal", "-shm"}) {
const std::filesystem::path sidecar = path.string() + suffix;
require(std::filesystem::exists(sidecar), "WAL sidecar was not created");
require_private_file(sidecar, "cache sidecar");
}
#endif
}
void test_existing_permissions_are_tightened(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "permissive.db";
{
SqliteConnection database{path};
database.execute("PRAGMA user_version=0");
}
require(::chmod(path.c_str(), 0666) == 0, "could not make fixture permissive");
SyncCache cache{path};
require_private_file(path, "existing cache database");
#else
static_cast<void>(root);
#endif
}
void test_account_calendars_and_deterministic_snapshot(const std::filesystem::path& root) {
SyncCache cache{root / "listing.db"};
auto account_b = account("account-b");
account_b.remote_subject = "subject-b";
auto account_a = account();
cache.upsert_account(account_b);
cache.upsert_account(account_a);
auto calendar_b = calendar("calendar-b");
calendar_b.remote_id = "remote-calendar-b";
calendar_b.name = "Calendar B";
auto calendar_a = calendar();
cache.replace_calendars_after_complete_listing(
account_a.id, std::vector<CachedCalendar>{calendar_b, calendar_a});
auto first = cache.snapshot();
require(first.accounts.size() == 2 && first.accounts[0].id == "account-a"
&& first.accounts[1].id == "account-b",
"accounts are not returned deterministically by ID");
require(first.calendars.size() == 2 && first.calendars[0].id == "calendar-a"
&& first.calendars[1].id == "calendar-b",
"calendars are not returned deterministically by ID");
calendar_a.name = "Renamed";
cache.replace_calendars_after_complete_listing(
account_a.id, std::vector<CachedCalendar>{calendar_a});
const auto second = cache.snapshot();
require(second.calendars.size() == 2, "omitted calendar was deleted");
require(second.calendars[0].active && second.calendars[0].name == "Renamed",
"listed calendar was not updated and kept active");
require(!second.calendars[1].active, "omitted calendar was not deactivated");
require(second == cache.snapshot(), "unchanged snapshots are not deterministic");
}
void test_pull_pages_replacement_tombstone_and_windows(const std::filesystem::path& root) {
SyncCache cache{root / "pull.db"};
seed_calendar(cache);
auto event_b = event("event-b");
event_b.remote_id = "remote-event-b";
event_b.uid = "uid-b";
event_b.raw_payload = R"({"event":"b"})";
auto instance_b = instance("instance-b", "event-b");
instance_b.title = "Event B";
PullPage first{{event_b, event()}, {instance_b, instance()}, checkpoint("cursor-1")};
cache.apply_pull_page(first);
const auto initial = cache.snapshot();
require(initial.events.size() == 2 && initial.instances.size() == 2,
"pull page did not atomically add events and instances");
require(initial.events[0].id == "event-a" && initial.events[1].id == "event-b"
&& initial.instances[0].id == "instance-a"
&& initial.instances[1].id == "instance-b",
"events and instances are not returned deterministically by ID");
require(initial.checkpoints.size() == 1 && initial.checkpoints[0].cursor == "cursor-1",
"pull page did not advance its cursor");
cache.apply_pull_page(first);
require(cache.snapshot() == initial, "repeated pull page was not idempotent");
auto changed = event();
changed.etag = "etag-a-2";
changed.raw_payload = R"({"event":"a2"})";
auto replacement = instance("instance-a-2");
replacement.title = "Changed A";
cache.apply_pull_page(page(changed, replacement, checkpoint("cursor-2")));
auto replaced = cache.snapshot();
require(replaced.events.size() == 2 && replaced.instances.size() == 2,
"changed event replacement affected an untouched event");
require(contains(std::vector<std::string>{replaced.instances[0].id, replaced.instances[1].id},
"instance-a-2")
&& contains(std::vector<std::string>{
replaced.instances[0].id, replaced.instances[1].id},
"instance-b"),
"changed event instances were not replaced independently");
auto tombstone = event();
tombstone.deleted = true;
tombstone.raw_payload.clear();
cache.apply_pull_page(PullPage{{tombstone}, {}, checkpoint("cursor-3")});
const auto deleted = cache.snapshot();
const auto deleted_event = std::ranges::find_if(
deleted.events, [](const CachedEvent& value) { return value.id == "event-a"; });
require(deleted_event != deleted.events.end() && deleted_event->deleted,
"tombstone did not retain a deleted event record");
require(deleted_event->raw_payload == changed.raw_payload,
"empty tombstone did not retain the prior raw payload");
require(std::ranges::none_of(deleted.instances,
[](const CachedEventInstance& value) { return value.event_id == "event-a"; }),
"tombstone retained event instances");
cache.apply_pull_page(PullPage{{}, {}, checkpoint("other-cursor", "calendar-a",
"second-window-start", "second-window-end")});
const auto windows = cache.snapshot();
require(windows.checkpoints.size() == 2,
"independent checkpoint windows for one calendar were overwritten");
require(windows.checkpoints[0].window_start == "opaque-start"
&& windows.checkpoints[1].window_start == "second-window-start",
"checkpoints are not returned in deterministic scope order");
}
void test_complete_window_idempotence_revival_and_omission(
const std::filesystem::path& root) {
const auto path = root / "complete-window.db";
SyncCache cache{path};
seed_calendar(cache);
auto completed = complete_window();
cache.replace_complete_window(completed);
const auto initial = cache.snapshot();
require(initial.events.size() == 1 && initial.instances.size() == 1
&& initial.checkpoints.size() == 1 && initial.checkpoints[0].complete,
"complete window did not persist event, instance, and complete checkpoint");
cache.replace_complete_window(completed);
require(cache.snapshot() == initial, "complete window replacement was not idempotent");
auto tombstone = event();
tombstone.deleted = true;
tombstone.raw_payload.clear();
cache.apply_pull_page(PullPage{{tombstone}, {}, checkpoint("delta-tombstone")});
require(cache.snapshot().events[0].deleted && cache.snapshot().instances.empty(),
"delta tombstone fixture did not delete the event");
auto revived = event();
revived.etag = "revived-etag";
revived.raw_payload = R"({"event":"revived"})";
auto revived_instance = instance("instance-revived");
auto revival_checkpoint = checkpoint("complete-revival");
revival_checkpoint.complete = true;
cache.replace_complete_window({{revived}, {revived_instance}, revival_checkpoint});
auto snapshot = cache.snapshot();
require(!snapshot.events[0].deleted && snapshot.events[0].etag == "revived-etag"
&& snapshot.events[0].raw_payload == revived.raw_payload
&& snapshot.instances.size() == 1
&& snapshot.instances[0].id == "instance-revived",
"returned complete-window event was not revived and replaced");
auto empty_checkpoint = checkpoint("complete-empty");
empty_checkpoint.complete = true;
cache.replace_complete_window({{}, {}, empty_checkpoint});
snapshot = cache.snapshot();
require(snapshot.events.size() == 1 && !snapshot.events[0].deleted
&& snapshot.events[0].raw_payload == revived.raw_payload
&& snapshot.instances.empty(),
"complete-window omission acted as a tombstone or retained last-window instances");
require(snapshot.checkpoints[0].cursor == "complete-empty"
&& snapshot.checkpoints[0].complete,
"empty completed window did not advance its checkpoint");
SqliteConnection inspection{path};
require(inspection.scalar_int("SELECT COUNT(*) FROM event_window_membership") == 0,
"empty completed window retained membership rows");
}
void test_overlapping_complete_windows_preserve_instances(
const std::filesystem::path& root) {
const auto path = root / "overlapping-windows.db";
SyncCache cache{path};
seed_calendar(cache);
auto first_checkpoint = checkpoint("first-complete", "calendar-a", "first-start", "first-end");
first_checkpoint.complete = true;
cache.replace_complete_window({{event()}, {instance()}, first_checkpoint});
auto second_instance = instance("instance-second");
second_instance.title = "Second window representation";
auto second_checkpoint =
checkpoint("second-complete", "calendar-a", "second-start", "second-end");
second_checkpoint.complete = true;
cache.replace_complete_window({{event()}, {second_instance}, second_checkpoint});
require(cache.snapshot().instances.size() == 1
&& cache.snapshot().instances[0].id == "instance-second",
"overlapping returned event did not replace its instances");
first_checkpoint.cursor = "first-empty";
cache.replace_complete_window({{}, {}, first_checkpoint});
require(cache.snapshot().instances.size() == 1
&& cache.snapshot().instances[0].id == "instance-second",
"omission from one overlapping window evicted a still-referenced event");
second_checkpoint.cursor = "second-empty";
cache.replace_complete_window({{}, {}, second_checkpoint});
require(cache.snapshot().instances.empty(),
"removing the final complete-window membership did not evict instances");
require(cache.snapshot().events.size() == 1 && !cache.snapshot().events[0].deleted,
"final membership removal deleted or tombstoned the event row");
SqliteConnection inspection{path};
require_throws(
[&] {
inspection.execute("PRAGMA foreign_keys=ON");
inspection.execute(
"INSERT INTO accounts VALUES('other-account','provider','subject','Other')");
inspection.execute(
"INSERT INTO calendars VALUES('other-calendar','other-account','remote','Other','',0,1,'raw')");
inspection.execute(
"INSERT INTO event_window_membership VALUES('other-calendar','start','end','event-a')");
},
"cross-calendar membership bypassed the composite foreign key");
}
void test_complete_window_validation_and_sql_rollback(const std::filesystem::path& root) {
SyncCache cache{root / "complete-validation.db"};
seed_calendar(cache);
auto event_b = event("event-b");
event_b.remote_id = "remote-event-b";
event_b.raw_payload = R"({"event":"b"})";
auto instance_b = instance("instance-b", "event-b");
cache.replace_complete_window(complete_window({event(), event_b}, {instance(), instance_b}));
const auto baseline = cache.snapshot();
auto expect_unchanged = [&](auto&& operation, std::string_view message) {
require_throws(std::forward<decltype(operation)>(operation), message);
require(cache.snapshot() == baseline, "invalid complete window changed cache state");
};
auto incomplete = complete_window();
incomplete.checkpoint.complete = false;
expect_unchanged([&] { cache.replace_complete_window(incomplete); },
"incomplete complete-window checkpoint accepted");
for (const int missing_field : {0, 1, 2, 3}) {
auto invalid = complete_window();
if (missing_field == 0) invalid.checkpoint.calendar_id.clear();
if (missing_field == 1) invalid.checkpoint.window_start.clear();
if (missing_field == 2) invalid.checkpoint.window_end.clear();
if (missing_field == 3) invalid.checkpoint.cursor.clear();
expect_unchanged([&] { cache.replace_complete_window(invalid); },
"empty complete-window checkpoint field accepted");
}
auto deleted = event();
deleted.deleted = true;
expect_unchanged([&] { cache.replace_complete_window(complete_window({deleted}, {})); },
"deleted complete-window event accepted");
auto cross_calendar = event();
cross_calendar.calendar_id = "other-calendar";
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({cross_calendar}, {})); },
"cross-calendar complete-window event accepted");
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event(), event()}, {})); },
"duplicate complete-window event accepted");
auto duplicate_remote = event("different-id");
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event(), duplicate_remote}, {})); },
"duplicate complete-window remote event accepted");
auto orphan = instance();
orphan.event_id = "not-returned";
expect_unchanged([&] { cache.replace_complete_window(complete_window({event()}, {orphan})); },
"orphan complete-window instance accepted");
auto invalid_interval = instance();
invalid_interval.end_epoch_microseconds = invalid_interval.start_epoch_microseconds - 1;
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event()}, {invalid_interval})); },
"invalid complete-window instance interval accepted");
auto changed = event();
changed.etag = "must-roll-back";
changed.raw_payload = R"({"event":"must-roll-back"})";
auto collision = instance("instance-b", "event-a");
auto collision_checkpoint = checkpoint("must-not-advance");
collision_checkpoint.complete = true;
expect_unchanged(
[&] { cache.replace_complete_window({{changed}, {collision}, collision_checkpoint}); },
"complete-window SQL instance collision was accepted");
cache.replace_calendars_after_complete_listing("account-a", {});
const auto inactive_baseline = cache.snapshot();
require_throws([&] { cache.replace_complete_window(complete_window()); },
"inactive calendar accepted a complete window");
require(cache.snapshot() == inactive_baseline,
"inactive-calendar complete window changed cache state");
}
void test_multiple_connections_serialize(const std::filesystem::path& root) {
const auto path = root / "connections.db";
SyncCache first{path};
SyncCache second{path};
std::exception_ptr first_error;
std::exception_ptr second_error;
std::atomic<bool> begin{false};
auto write = [&begin](SyncCache& cache, CachedAccount value, std::exception_ptr& error) {
while (!begin.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
try {
cache.upsert_account(value);
} catch (...) {
error = std::current_exception();
}
};
auto account_b = account("account-b");
account_b.remote_subject = "subject-b";
std::thread one{write, std::ref(first), account(), std::ref(first_error)};
std::thread two{write, std::ref(second), account_b, std::ref(second_error)};
begin.store(true, std::memory_order_release);
one.join();
two.join();
require(first_error == nullptr && second_error == nullptr,
"ordinary concurrent writes leaked SQLITE_BUSY or another error");
const auto snapshot = first.snapshot();
require(snapshot.accounts.size() == 2, "serialized connections lost a write");
}
void test_input_validation_and_rollback(const std::filesystem::path& root) {
SyncCache cache{root / "validation.db"};
seed_calendar(cache);
auto account_b = account("account-b");
account_b.remote_subject = "subject-b";
cache.upsert_account(account_b);
auto calendar_b = calendar("calendar-b", "account-b");
calendar_b.remote_id = "remote-calendar-b";
calendar_b.raw_payload = R"({"calendar":"b"})";
cache.replace_calendars_after_complete_listing("account-b", std::vector{calendar_b});
cache.apply_pull_page(page());
const CacheSnapshot baseline = cache.snapshot();
auto expect_unchanged = [&](auto&& operation, std::string_view message) {
require_throws(std::forward<decltype(operation)>(operation), message);
require(cache.snapshot() == baseline, "failed operation changed the cache snapshot");
};
auto empty_account = account();
empty_account.id.clear();
expect_unchanged([&] { cache.upsert_account(empty_account); }, "empty account ID accepted");
auto reassigned = account();
reassigned.remote_subject = "different-subject";
expect_unchanged([&] { cache.upsert_account(reassigned); },
"account local ID identity reassignment accepted");
auto provider_reassigned = account();
provider_reassigned.provider = "different-provider";
expect_unchanged([&] { cache.upsert_account(provider_reassigned); },
"account provider identity reassignment accepted");
auto duplicate_calendar = calendar();
expect_unchanged(
[&] {
cache.replace_calendars_after_complete_listing(
"account-a", std::vector<CachedCalendar>{duplicate_calendar, duplicate_calendar});
},
"duplicate calendar ID accepted");
auto duplicate_remote_calendar = calendar("calendar-new");
expect_unchanged(
[&] {
cache.replace_calendars_after_complete_listing("account-a",
std::vector<CachedCalendar>{duplicate_calendar, duplicate_remote_calendar});
},
"duplicate calendar remote ID accepted");
auto empty_calendar = calendar();
empty_calendar.id.clear();
expect_unchanged(
[&] {
cache.replace_calendars_after_complete_listing(
"account-a", std::vector<CachedCalendar>{empty_calendar});
},
"empty calendar ID accepted");
auto wrong_account = calendar();
wrong_account.account_id = "account-b";
expect_unchanged(
[&] {
cache.replace_calendars_after_complete_listing(
"account-a", std::vector<CachedCalendar>{wrong_account});
},
"cross-account calendar accepted");
auto reused_calendar_id = calendar("calendar-a", "account-b");
reused_calendar_id.remote_id = "different-remote-calendar";
expect_unchanged(
[&] {
cache.replace_calendars_after_complete_listing(
"account-b", std::vector<CachedCalendar>{reused_calendar_id});
},
"calendar local ID was reassigned across accounts");
auto inactive_input = calendar();
inactive_input.active = false;
expect_unchanged(
[&] {
cache.replace_calendars_after_complete_listing(
"account-a", std::vector<CachedCalendar>{inactive_input});
},
"inactive completed-listing input accepted");
auto missing_raw_calendar = calendar();
missing_raw_calendar.raw_payload.clear();
expect_unchanged(
[&] {
cache.replace_calendars_after_complete_listing(
"account-a", std::vector<CachedCalendar>{missing_raw_calendar});
},
"calendar without raw payload accepted");
auto duplicate_event = event();
expect_unchanged(
[&] {
cache.apply_pull_page(PullPage{{duplicate_event, duplicate_event}, {},
checkpoint("duplicate-cursor")});
},
"duplicate event ID accepted");
auto duplicate_remote_event = event("event-new");
expect_unchanged(
[&] {
cache.apply_pull_page(PullPage{{duplicate_event, duplicate_remote_event}, {},
checkpoint("duplicate-remote-event")});
},
"duplicate event remote ID accepted");
auto empty_event = event();
empty_event.id.clear();
expect_unchanged(
[&] { cache.apply_pull_page(page(empty_event, instance(), checkpoint("empty-event"))); },
"empty event ID accepted");
auto cross_calendar_event = event();
cross_calendar_event.calendar_id = "calendar-b";
expect_unchanged(
[&] {
cache.apply_pull_page(
PullPage{{cross_calendar_event}, {}, checkpoint("cross-calendar")});
},
"cross-calendar event accepted");
auto reassigned_event = event("event-a", "calendar-b");
reassigned_event.remote_id = "different-remote-event";
expect_unchanged(
[&] {
cache.apply_pull_page(PullPage{{reassigned_event}, {},
checkpoint("reassigned-event", "calendar-b")});
},
"event local ID was reassigned across calendars");
auto missing_remote = event();
missing_remote.remote_id.clear();
expect_unchanged(
[&] { cache.apply_pull_page(page(missing_remote, instance(), checkpoint("remote"))); },
"event without remote ID accepted");
auto missing_raw_event = event();
missing_raw_event.raw_payload.clear();
expect_unchanged(
[&] { cache.apply_pull_page(page(missing_raw_event, instance(), checkpoint("raw"))); },
"nondeleted event without raw payload accepted");
auto duplicate_instance = instance();
expect_unchanged(
[&] {
cache.apply_pull_page(PullPage{{event()}, {duplicate_instance, duplicate_instance},
checkpoint("duplicate-instance")});
},
"duplicate instance ID accepted");
auto empty_instance = instance();
empty_instance.id.clear();
expect_unchanged(
[&] { cache.apply_pull_page(page(event(), empty_instance, checkpoint("empty-instance"))); },
"empty instance ID accepted");
auto orphan = instance();
orphan.event_id = "event-not-in-page";
expect_unchanged(
[&] { cache.apply_pull_page(page(event(), orphan, checkpoint("orphan"))); },
"orphan instance accepted");
auto backwards = instance();
backwards.end_epoch_microseconds = backwards.start_epoch_microseconds - 1;
expect_unchanged(
[&] { cache.apply_pull_page(page(event(), backwards, checkpoint("backwards"))); },
"backwards instance interval accepted");
auto empty_all_day = instance();
empty_all_day.all_day = true;
empty_all_day.end_epoch_microseconds = empty_all_day.start_epoch_microseconds;
expect_unchanged(
[&] { cache.apply_pull_page(page(event(), empty_all_day, checkpoint("all-day"))); },
"empty all-day interval accepted");
auto deleted_with_instance = event();
deleted_with_instance.deleted = true;
deleted_with_instance.raw_payload.clear();
expect_unchanged(
[&] {
cache.apply_pull_page(
page(deleted_with_instance, instance(), checkpoint("deleted-instance")));
},
"instance attached to tombstoned event accepted");
auto negative = event();
negative.id = "event-negative";
negative.remote_id = "remote-negative";
negative.uid = "uid-negative";
auto before_epoch = instance("instance-negative", "event-negative");
before_epoch.start_epoch_microseconds = -2'000'000;
before_epoch.end_epoch_microseconds = -1'000'000;
cache.apply_pull_page(page(negative, before_epoch, checkpoint("negative")));
require(std::ranges::any_of(cache.snapshot().instances,
[](const CachedEventInstance& value) {
return value.id == "instance-negative"
&& value.start_epoch_microseconds < 0;
}),
"valid pre-1970 instance was rejected");
}
void test_sql_failure_rolls_back_snapshot_and_cursor(const std::filesystem::path& root) {
SyncCache cache{root / "sql-rollback.db"};
seed_calendar(cache);
auto event_b = event("event-b");
event_b.remote_id = "remote-event-b";
event_b.uid = "uid-b";
event_b.raw_payload = R"({"event":"b"})";
auto instance_b = instance("instance-b", "event-b");
cache.apply_pull_page(
PullPage{{event(), event_b}, {instance(), instance_b}, checkpoint("baseline-cursor")});
const auto baseline = cache.snapshot();
auto changed = event();
changed.etag = "must-roll-back";
changed.raw_payload = R"({"event":"must-roll-back"})";
auto colliding_instance = instance("instance-b", "event-a");
require_throws(
[&] {
cache.apply_pull_page(
page(changed, colliding_instance, checkpoint("must-not-advance")));
},
"cross-event instance ID collision was accepted");
require(cache.snapshot() == baseline,
"SQL failure did not roll back event, instance, and cursor state together");
}
void test_checkpoint_calendar_and_shape_validation(const std::filesystem::path& root) {
const auto path = root / "checkpoint-validation.db";
SyncCache cache{path};
seed_calendar(cache);
auto expect_empty = [&](auto&& operation, std::string_view message) {
require_throws(std::forward<decltype(operation)>(operation), message);
require(cache.snapshot().events.empty() && cache.snapshot().checkpoints.empty(),
"invalid pull partially changed event or checkpoint state");
};
expect_empty(
[&] { cache.apply_pull_page(page(event(), instance(), checkpoint("", "calendar-a"))); },
"empty checkpoint cursor accepted");
expect_empty(
[&] {
cache.apply_pull_page(page(event(), instance(),
checkpoint("cursor", "calendar-a", "only-start", "")));
},
"half-specified checkpoint window accepted");
expect_empty(
[&] {
cache.apply_pull_page(page(event(), instance(),
checkpoint("cursor", "calendar-a", "", "only-end")));
},
"half-specified checkpoint window accepted");
expect_empty(
[&] {
cache.apply_pull_page(
page(event(), instance(), checkpoint("cursor", "missing-calendar")));
},
"missing checkpoint calendar accepted");
cache.apply_pull_page(PullPage{{}, {}, checkpoint("empty-window", "calendar-a", "", "")});
require(cache.snapshot().checkpoints.size() == 1
&& cache.snapshot().checkpoints[0].window_start.empty()
&& cache.snapshot().checkpoints[0].window_end.empty(),
"valid unwindowed opaque checkpoint was rejected");
cache.replace_calendars_after_complete_listing("account-a", {});
require(!cache.snapshot().calendars[0].active, "fixture calendar was not deactivated");
require_throws(
[&] { cache.apply_pull_page(page()); }, "inactive checkpoint calendar accepted");
require(cache.snapshot().events.empty(), "inactive-calendar pull changed event state");
}
void test_foreign_key_failure_rolls_back(const std::filesystem::path& root) {
const auto path = root / "foreign-key.db";
SyncCache cache{path};
seed_calendar(cache);
cache.apply_pull_page(page());
const auto baseline = cache.snapshot();
SqliteConnection external{path};
external.execute("PRAGMA foreign_keys=ON");
require_throws(
[&] { external.execute("DELETE FROM accounts WHERE id='account-a'"); },
"foreign-key violating direct delete succeeded");
require(cache.snapshot() == baseline, "foreign-key failure changed cached data");
}
void downgrade_fixture_to_v1(const std::filesystem::path& path) {
SqliteConnection database{path};
database.execute("DROP TABLE event_window_membership");
database.execute("DROP INDEX events_id_calendar_id_unique_idx");
database.execute("PRAGMA user_version=1");
}
void test_v1_to_v2_migration_preserves_data_and_serializes(
const std::filesystem::path& root) {
const auto path = root / "v1-upgrade.db";
CacheSnapshot before;
{
SyncCache cache{path};
seed_calendar(cache);
cache.apply_pull_page(page());
before = cache.snapshot();
}
downgrade_fixture_to_v1(path);
{
SyncCache upgraded{path};
require(upgraded.snapshot() == before, "v1 to v2 migration changed cached data");
}
{
SqliteConnection inspection{path};
require(inspection.scalar_int("PRAGMA user_version") == 2,
"v1 migration did not record schema version 2");
require(inspection.scalar_int(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' "
"AND name='event_window_membership'")
== 1,
"v1 migration did not create complete-window membership");
}
const auto concurrent_path = root / "v1-concurrent.db";
{
SyncCache cache{concurrent_path};
seed_calendar(cache);
}
downgrade_fixture_to_v1(concurrent_path);
std::atomic<bool> begin{false};
std::exception_ptr first_error;
std::exception_ptr second_error;
auto open = [&](std::exception_ptr& error) {
while (!begin.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
try {
SyncCache cache{concurrent_path};
(void)cache.snapshot();
} catch (...) {
error = std::current_exception();
}
};
std::thread first{open, std::ref(first_error)};
std::thread second{open, std::ref(second_error)};
begin.store(true, std::memory_order_release);
first.join();
second.join();
require(first_error == nullptr && second_error == nullptr,
"concurrent v1 opens did not serialize schema migration");
SqliteConnection concurrent_inspection{concurrent_path};
require(concurrent_inspection.scalar_int("PRAGMA user_version") == 2,
"concurrent migration did not finish at schema version 2");
}
void test_migration_failures_are_non_destructive(const std::filesystem::path& root) {
const auto collision_path = root / "collision.db";
{
SqliteConnection database{collision_path};
database.execute("CREATE TABLE accounts (sentinel TEXT NOT NULL)");
database.execute("INSERT INTO accounts VALUES ('keep-me')");
database.execute("PRAGMA user_version=0");
}
require_throws([&] { SyncCache cache{collision_path}; },
"incompatible migration collision was accepted");
{
SqliteConnection database{collision_path};
require(database.scalar_int("PRAGMA user_version") == 0,
"failed migration changed schema version");
require(database.scalar_text("SELECT sentinel FROM accounts") == "keep-me",
"failed migration changed preexisting data");
require(database.scalar_int(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name!='accounts'")
== 0,
"failed migration left partially created v1 tables");
}
const auto v1_collision_path = root / "v1-collision.db";
{
SyncCache cache{v1_collision_path};
seed_calendar(cache);
cache.apply_pull_page(page());
}
downgrade_fixture_to_v1(v1_collision_path);
{
SqliteConnection database{v1_collision_path};
database.execute("CREATE TABLE event_window_membership (sentinel TEXT NOT NULL)");
database.execute("INSERT INTO event_window_membership VALUES ('keep-v1')");
}
require_throws([&] { SyncCache cache{v1_collision_path}; },
"incompatible v1 to v2 migration collision was accepted");
{
SqliteConnection database{v1_collision_path};
require(database.scalar_int("PRAGMA user_version") == 1,
"failed v2 migration changed schema version");
require(database.scalar_text("SELECT sentinel FROM event_window_membership") == "keep-v1",
"failed v2 migration changed preexisting data");
require(database.scalar_int(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' "
"AND name='events_id_calendar_id_unique_idx'")
== 0,
"failed v2 migration left a partially created index");
require(database.scalar_int("SELECT COUNT(*) FROM events") == 1,
"failed v2 migration changed v1 event data");
}
const auto future_path = root / "future.db";
{
SqliteConnection database{future_path};
database.execute("CREATE TABLE future_sentinel (value TEXT NOT NULL)");
database.execute("INSERT INTO future_sentinel VALUES ('future-data')");
database.execute("PRAGMA user_version=3");
}
require_throws([&] { SyncCache cache{future_path}; }, "future schema version was accepted");
{
SqliteConnection database{future_path};
require(database.scalar_int("PRAGMA user_version") == 3,
"future schema rejection changed schema version");
require(database.scalar_text("SELECT value FROM future_sentinel") == "future-data",
"future schema rejection changed data");
require(database.scalar_int("SELECT COUNT(*) FROM sqlite_master WHERE type='table'") == 1,
"future schema rejection created v1 tables");
}
}
void test_invalid_files_are_refused(const std::filesystem::path& root) {
const auto corrupt_path = root / "corrupt.db";
{
std::ofstream output(corrupt_path, std::ios::binary);
output << "this is not a SQLite database";
require(static_cast<bool>(output), "failed to write corrupt database fixture");
}
const std::string before = [&] {
std::ifstream input(corrupt_path, std::ios::binary);
return std::string{std::istreambuf_iterator<char>{input}, {}};
}();
require_throws([&] { SyncCache cache{corrupt_path}; }, "non-SQLite cache file was accepted");
std::ifstream after_input(corrupt_path, std::ios::binary);
const std::string after{std::istreambuf_iterator<char>{after_input}, {}};
require(after == before, "corrupt cache rejection changed the source file");
#if !defined(_WIN32)
const auto target_path = root / "symlink-target.db";
{
SqliteConnection target{target_path};
}
const auto symlink_path = root / "symlink.db";
require(::symlink(target_path.c_str(), symlink_path.c_str()) == 0,
"failed to create symlink fixture");
require_throws([&] { SyncCache cache{symlink_path}; }, "symlink cache path was accepted");
const auto directory_path = root / "directory.db";
std::filesystem::create_directory(directory_path);
require_throws([&] { SyncCache cache{directory_path}; },
"nonregular cache path was accepted");
#endif
}
} // namespace
int main() {
try {
TempDirectory temporary;
test_fresh_schema_permissions_and_secrets(temporary.path());
test_existing_permissions_are_tightened(temporary.path());
test_account_calendars_and_deterministic_snapshot(temporary.path());
test_pull_pages_replacement_tombstone_and_windows(temporary.path());
test_complete_window_idempotence_revival_and_omission(temporary.path());
test_overlapping_complete_windows_preserve_instances(temporary.path());
test_complete_window_validation_and_sql_rollback(temporary.path());
test_multiple_connections_serialize(temporary.path());
test_input_validation_and_rollback(temporary.path());
test_sql_failure_rolls_back_snapshot_and_cursor(temporary.path());
test_checkpoint_calendar_and_shape_validation(temporary.path());
test_foreign_key_failure_rolls_back(temporary.path());
test_v1_to_v2_migration_preserves_data_and_serializes(temporary.path());
test_migration_failures_are_non_destructive(temporary.path());
test_invalid_files_are_refused(temporary.path());
} catch (const std::exception& error) {
std::cerr << "sync cache test failure: " << error.what() << '\n';
return 1;
}
std::cout << "sync cache tests passed\n";
return 0;
}