Files
metabuilder/dbal/production/tests/integration/postgres_integration_test.cpp
2026-03-09 22:30:41 +00:00

497 lines
19 KiB
C++

/**
* @file postgres_integration_test.cpp
* @brief DBAL integration tests against a real PostgreSQL database.
*
* The PostgreSQL container is started in ContainerEnvironment (test_main.cpp)
* alongside the MySQL container, both in parallel. Each test creates its own
* adapter, seeds 8 rows, and cleans up after itself.
* Ryuk cleans the container on process exit.
*
* Run locally:
* ctest -R dbal_integration_tests --output-on-failure
*/
#include <gtest/gtest.h>
#include <thread>
#include <vector>
#include <atomic>
#include "../../src/adapters/sql/postgres_adapter.hpp"
#include "../../src/adapters/sql/sql_connection.hpp"
#include "../../include/dbal/core/types.hpp"
#include "../helpers/integration_fixture.hpp"
using namespace dbal;
using namespace dbal::adapters::sql;
using namespace dbal_test;
// ─── Fixture ─────────────────────────────────────────────────────────────────
class PostgresIntegrationTest : public IntegrationFixture {
protected:
void SetUp() override {
if (!g_containers.postgres.valid) {
GTEST_SKIP() << "PostgreSQL container unavailable: "
<< g_containers.postgres.error_msg;
}
adapter_url_ = "postgresql://testuser:testpass@127.0.0.1:"
+ std::to_string(g_containers.postgres.host_port) + "/dbal_test";
IntegrationFixture::SetUp();
}
void TearDown() override {
IntegrationFixture::TearDown();
}
std::unique_ptr<dbal::adapters::Adapter> createAdapter() override {
ParsedUrl p = parseUrl(adapter_url_);
SqlConnectionConfig cfg;
cfg.host = p.host;
cfg.port = p.port > 0 ? p.port : 5432;
cfg.database = p.database;
cfg.user = p.user;
cfg.password = p.password;
return std::make_unique<PostgresAdapter>(cfg);
}
void dropTestTable() override {
nlohmann::json empty_filter = nlohmann::json::object();
adapter->deleteMany("TestItem", empty_filter);
}
};
// ─── Same battery as SQLite (all operators) ───────────────────────────────────
TEST_F(PostgresIntegrationTest, Create_Read_Update_Delete) {
nlohmann::json data;
data["id"] = "cc000000-0000-0000-0000-000000000001";
data["title"] = "pg-lifecycle";
data["language"] = "postgres";
data["score"] = 99;
data["tenantId"] = "tenantPG";
data["createdAt"]= 1700099999LL;
auto created = adapter->create("TestItem", data);
ASSERT_TRUE(created.isOk()) << created.error().what();
EXPECT_EQ(created.value()["title"].get<std::string>(), "pg-lifecycle");
auto read = adapter->read("TestItem", "cc000000-0000-0000-0000-000000000001");
ASSERT_TRUE(read.isOk());
EXPECT_EQ(read.value()["language"].get<std::string>(), "postgres");
nlohmann::json patch;
patch["title"] = "pg-updated";
auto updated = adapter->update("TestItem", "cc000000-0000-0000-0000-000000000001", patch);
ASSERT_TRUE(updated.isOk());
auto removed = adapter->remove("TestItem", "cc000000-0000-0000-0000-000000000001");
ASSERT_TRUE(removed.isOk());
auto gone = adapter->read("TestItem", "cc000000-0000-0000-0000-000000000001");
EXPECT_FALSE(gone.isOk());
}
TEST_F(PostgresIntegrationTest, List_Eq_TenantA) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value().total, 6u);
}
TEST_F(PostgresIntegrationTest, List_Lt_Score) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.conditions.push_back({"score", FilterOp::Lt, "25"});
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk());
EXPECT_EQ(result.value().total, 2u);
}
TEST_F(PostgresIntegrationTest, List_Like_Prefix) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.conditions.push_back({"title", FilterOp::Like, "py-%"});
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk());
EXPECT_EQ(result.value().total, 3u);
}
TEST_F(PostgresIntegrationTest, List_ILike_NativePostgres) {
// PostgreSQL uses native ILIKE (not LOWER workaround)
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.conditions.push_back({"language", FilterOp::ILike, "PYTHON"});
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value().total, 3u);
}
TEST_F(PostgresIntegrationTest, List_IsNull_Language) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.conditions.push_back({"language", FilterOp::IsNull, ""});
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk());
EXPECT_EQ(result.value().total, 1u);
}
TEST_F(PostgresIntegrationTest, List_In_Languages) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
FilterCondition cond;
cond.field = "language";
cond.op = FilterOp::In;
cond.values = {"python", "typescript"};
opts.conditions.push_back(cond);
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk());
EXPECT_EQ(result.value().total, 5u);
}
TEST_F(PostgresIntegrationTest, List_Between) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
FilterCondition cond;
cond.field = "score";
cond.op = FilterOp::Between;
cond.values = {"15", "45"};
opts.conditions.push_back(cond);
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk());
EXPECT_EQ(result.value().total, 3u); // 20, 30, 40
}
TEST_F(PostgresIntegrationTest, List_OrGroup) {
FilterGroup grp;
grp.conditions.push_back({"language", FilterOp::Eq, "python"});
grp.conditions.push_back({"language", FilterOp::Eq, "typescript"});
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.filter_groups.push_back(grp);
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk());
EXPECT_EQ(result.value().total, 5u);
}
TEST_F(PostgresIntegrationTest, Aggregate_CountByLanguage) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.aggregates.push_back({AggFunc::Count, "id", "count"});
opts.group_by = {"language"};
opts.limit = 100;
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk()) << result.error().what();
bool found_python = false;
for (const auto& item : result.value().items) {
if (item.contains("language") && !item["language"].is_null() &&
item["language"].get<std::string>() == "python") {
found_python = true;
int cnt = item["count"].is_string()
? std::stoi(item["count"].get<std::string>())
: item["count"].get<int>();
EXPECT_EQ(cnt, 3);
}
}
EXPECT_TRUE(found_python);
}
TEST_F(PostgresIntegrationTest, Pagination_TotalIsAccurate) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.page = 1;
opts.limit = 2;
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk());
EXPECT_EQ(result.value().total, 6u); // total = COUNT(*), not limit
EXPECT_EQ(result.value().items.size(), 2u);
}
TEST_F(PostgresIntegrationTest, TenantIsolation) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.limit = 100;
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk());
for (const auto& item : result.value().items) {
EXPECT_EQ(item["tenantId"].get<std::string>(), "tenantA");
}
}
// ─── Bulk operations ─────────────────────────────────────────────────────────
TEST_F(PostgresIntegrationTest, CreateMany_InsertsRows) {
std::vector<nlohmann::json> records;
for (int i = 0; i < 3; ++i) {
nlohmann::json row;
row["id"] = "ee00000" + std::to_string(i+1) + "-0000-0000-0000-000000000000";
row["title"] = "pg-bulk-" + std::to_string(i);
row["language"] = "elixir";
row["score"] = i + 1;
row["tenantId"] = "tenantPGBulk";
row["createdAt"]= 1700020000LL + i;
records.push_back(row);
}
auto result = adapter->createMany("TestItem", records);
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value(), 3);
ListOptions opts;
opts.filter["tenantId"] = "tenantPGBulk";
auto list = adapter->list("TestItem", opts);
ASSERT_TRUE(list.isOk());
EXPECT_EQ(list.value().total, 3u);
}
TEST_F(PostgresIntegrationTest, UpdateMany_ByTenant) {
nlohmann::json filter;
filter["tenantId"] = "tenantA";
nlohmann::json data;
data["language"] = "pg-updated";
auto result = adapter->updateMany("TestItem", filter, data);
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value(), 6);
// Verify the update
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.conditions.push_back({"language", FilterOp::Eq, "pg-updated"});
auto list = adapter->list("TestItem", opts);
ASSERT_TRUE(list.isOk());
EXPECT_EQ(list.value().total, 6u);
}
TEST_F(PostgresIntegrationTest, DeleteMany_WithFilter) {
nlohmann::json filter;
filter["language"] = "python";
auto result = adapter->deleteMany("TestItem", filter);
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value(), 3);
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.conditions.push_back({"language", FilterOp::Eq, "python"});
auto list = adapter->list("TestItem", opts);
ASSERT_TRUE(list.isOk());
EXPECT_EQ(list.value().total, 0u);
}
// ─── FindFirst + FindByField + Upsert ────────────────────────────────────────
TEST_F(PostgresIntegrationTest, FindFirst_Match) {
nlohmann::json filter;
filter["language"] = "python";
auto result = adapter->findFirst("TestItem", filter);
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value()["language"].get<std::string>(), "python");
}
TEST_F(PostgresIntegrationTest, FindFirst_NoMatch_ReturnsError) {
nlohmann::json filter;
filter["language"] = "cobol";
auto result = adapter->findFirst("TestItem", filter);
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, FindByField_Match) {
auto result = adapter->findByField("TestItem", "language", nlohmann::json("typescript"));
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value()["language"].get<std::string>(), "typescript");
}
TEST_F(PostgresIntegrationTest, Upsert_CreatesWhenMissing) {
nlohmann::json createData;
createData["id"] = "bbbb0001-0000-0000-0000-000000000001";
createData["title"] = "pg-upsert-new";
createData["language"] = "scala";
createData["score"] = 42;
createData["tenantId"] = "tenantPGUp";
createData["createdAt"]= 1700098000LL;
auto result = adapter->upsert("TestItem", "id",
nlohmann::json("bbbb0001-0000-0000-0000-000000000001"),
createData, nlohmann::json::object());
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value()["title"].get<std::string>(), "pg-upsert-new");
}
TEST_F(PostgresIntegrationTest, Upsert_UpdatesWhenExists) {
// Seed row with id a0000000-0000-0000-0000-000000000001 exists (py-alpha)
nlohmann::json updateData;
updateData["title"] = "py-alpha-upserted";
auto result = adapter->upsert("TestItem", "id",
nlohmann::json("a0000000-0000-0000-0000-000000000001"),
nlohmann::json::object(), updateData);
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value()["title"].get<std::string>(), "py-alpha-upserted");
}
// ─── Transactions ─────────────────────────────────────────────────────────────
TEST_F(PostgresIntegrationTest, Transaction_Rollback_ApiSucceeds) {
// SqlTransactionManager is a state-management stub — BEGIN/ROLLBACK are not emitted
// to the DB. Verify the API state machine: begin → rollback returns OK; a second
// rollback (no active tx) returns an error.
ASSERT_TRUE(adapter->beginTransaction().isOk());
ASSERT_TRUE(adapter->rollbackTransaction().isOk());
EXPECT_FALSE(adapter->rollbackTransaction().isOk());
}
TEST_F(PostgresIntegrationTest, Transaction_Commit_ApiSucceeds) {
// Same stub note as above. Verify begin → commit returns OK; second commit errors.
ASSERT_TRUE(adapter->beginTransaction().isOk());
ASSERT_TRUE(adapter->commitTransaction().isOk());
EXPECT_FALSE(adapter->commitTransaction().isOk());
}
// ─── Metadata ─────────────────────────────────────────────────────────────────
TEST_F(PostgresIntegrationTest, GetAvailableEntities) {
auto result = adapter->getAvailableEntities();
ASSERT_TRUE(result.isOk()) << result.error().what();
bool found = std::find(result.value().begin(), result.value().end(), "TestItem")
!= result.value().end();
EXPECT_TRUE(found);
}
TEST_F(PostgresIntegrationTest, GetEntitySchema_ReturnsFields) {
auto result = adapter->getEntitySchema("TestItem");
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value().name, "TestItem");
EXPECT_GE(result.value().fields.size(), 5u);
}
TEST_F(PostgresIntegrationTest, GetEntitySchema_UnknownEntity_ReturnsError) {
auto result = adapter->getEntitySchema("NoSuchEntity");
EXPECT_FALSE(result.isOk());
}
// ─── Sort + error paths ──────────────────────────────────────────────────────
TEST_F(PostgresIntegrationTest, List_SortDesc) {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.conditions.push_back({"language", FilterOp::Eq, "python"});
opts.sort["score"] = "desc";
opts.limit = 10;
auto result = adapter->list("TestItem", opts);
ASSERT_TRUE(result.isOk()) << result.error().what();
EXPECT_EQ(result.value().total, 3u);
const auto& items = result.value().items;
ASSERT_EQ(items.size(), 3u);
int prev = 1000;
for (const auto& item : items) {
int score = item["score"].is_string()
? std::stoi(item["score"].get<std::string>())
: item["score"].get<int>();
EXPECT_LT(score, prev);
prev = score;
}
}
TEST_F(PostgresIntegrationTest, Read_UnknownEntity_ReturnsError) {
auto result = adapter->read("NoSuchEntity", "any-id");
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, FindFirst_UnknownEntity_ReturnsError) {
auto result = adapter->findFirst("NoSuchEntity", nlohmann::json::object());
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, UpdateMany_UnknownEntity_ReturnsError) {
auto result = adapter->updateMany("NoSuchEntity",
nlohmann::json::object(),
nlohmann::json::object());
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, UpdateMany_UnknownFilterField_ReturnsError) {
// Covers sql_adapter_bulk.cpp updateMany: WHERE clause schema-validation guard
nlohmann::json filter;
filter["nonExistentColumn"] = "value";
nlohmann::json data;
data["language"] = "x";
auto result = adapter->updateMany("TestItem", filter, data);
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, DeleteMany_UnknownEntity_ReturnsError) {
auto result = adapter->deleteMany("NoSuchEntity", nlohmann::json::object());
EXPECT_FALSE(result.isOk());
}
// ─── Validation: unknown field guards ────────────────────────────────────────
TEST_F(PostgresIntegrationTest, FindFirst_UnknownFilterField_ReturnsError) {
// Covers sql_adapter_query.cpp findFirst field-validation guard
nlohmann::json filter;
filter["nonExistentColumn"] = "x";
auto result = adapter->findFirst("TestItem", filter);
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, List_SortByUnknownField_ReturnsError) {
// Covers sql_adapter_crud.cpp list() sort validation catch block
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.sort["nonExistentColumn"] = "asc";
auto result = adapter->list("TestItem", opts);
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, List_ConditionOnUnknownField_ReturnsError) {
// Covers sql_adapter_crud.cpp list() where-clause validation catch block
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.conditions.push_back({"nonExistentColumn", FilterOp::Eq, "x"});
auto result = adapter->list("TestItem", opts);
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, List_AggregateOnUnknownField_ReturnsError) {
// Covers sql_adapter_crud.cpp list() aggregate field validation guard
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.aggregates.push_back({AggFunc::Count, "nonExistentColumn", "cnt"});
opts.group_by = {"language"};
auto result = adapter->list("TestItem", opts);
EXPECT_FALSE(result.isOk());
}
TEST_F(PostgresIntegrationTest, List_GroupByUnknownField_ReturnsError) {
// Covers sql_adapter_crud.cpp list() group_by field validation guard
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.aggregates.push_back({AggFunc::Count, "id", "cnt"});
opts.group_by = {"nonExistentColumn"};
auto result = adapter->list("TestItem", opts);
EXPECT_FALSE(result.isOk());
}
// ─── Postgres-specific: concurrent access ────────────────────────────────────
TEST_F(PostgresIntegrationTest, ConcurrentReads_NoDataRace) {
std::atomic<int> success_count{0};
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
threads.emplace_back([&]() {
ListOptions opts;
opts.filter["tenantId"] = "tenantA";
opts.limit = 10;
auto result = adapter->list("TestItem", opts);
if (result.isOk()) success_count.fetch_add(1);
});
}
for (auto& t : threads) t.join();
EXPECT_EQ(success_count.load(), 5) << "Not all concurrent reads succeeded";
}