#include <filesystem>
#include <format>
#include <chrono>
#include <iostream>
#include <netinet/in.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#include <httplib.h>
#include <sys/socket.h>
#include <unistd.h>
#include "api_server.hpp"
#include "database.hpp"
#include "delivery_store.hpp"
#include "key_store.hpp"
#include "subscription_store.hpp"
#include "telegram_client.hpp"
#include "service_error.hpp"
namespace
{
std::filesystem::path makeDirectory()
{
std::string pattern = "/tmp/telegrammer-test-XXXXXX";
std::vector<char> buffer(pattern.begin(), pattern.end());
buffer.push_back('\0');
char* result = ::mkdtemp(buffer.data());
if(result == nullptr)
{
throw std::runtime_error("mkdtemp failed");
}
return result;
}
void require(bool condition, std::string_view message)
{
if(!condition)
{
throw std::runtime_error(std::string(message));
}
}
int freePort()
{
int socket_fd = ::socket(AF_INET, SOCK_STREAM, 0);
require(socket_fd >= 0, "socket failed");
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = 0;
require(::bind(socket_fd, reinterpret_cast<sockaddr*>(&address),
sizeof(address)) == 0,
"bind failed");
socklen_t length = sizeof(address);
require(::getsockname(socket_fd, reinterpret_cast<sockaddr*>(&address),
&length) == 0,
"getsockname failed");
int port = ntohs(address.sin_port);
::close(socket_fd);
return port;
}
class FakeTelegram final : public telegrammer::TelegramApi
{
public:
mutable int64_t sent_chat_id = 0;
mutable std::string sent_text;
mw::E<telegrammer::json> getMe() const override
{
return telegrammer::json{
{"ok", true}, {"result", {{"id", 77}, {"is_bot", true}}}};
}
mw::E<telegrammer::json> sendMessage(
int64_t chat_id, const std::string& text) const override
{
sent_chat_id = chat_id;
sent_text = text;
return telegrammer::json{
{"ok", true},
{"result", {{"message_id", 42}, {"chat", {{"id", chat_id}}}}}};
}
mw::E<telegrammer::json> getUpdates(
int64_t, int) const override
{
return telegrammer::json{
{"ok", true}, {"result", telegrammer::json::array()}};
}
};
void requireJsonResponse(const httplib::Result& response, int status,
std::string_view message)
{
require(response != nullptr, message);
require(response->status == status, message);
}
} // namespace
int main()
{
auto directory = makeDirectory();
try
{
auto database = directory / "state.db";
std::string database_path = database.string();
auto initialized = telegrammer::initializeDatabase(database_path);
require(initialized.has_value(), "database initialization failed");
auto database_connection = telegrammer::openDatabase(database_path);
require(database_connection.has_value(), "database could not be reopened");
auto user_version = (*database_connection)->evalToValue<int64_t>(
"PRAGMA user_version;");
require(user_version.has_value() && *user_version == 0,
"development schema unexpectedly changed user_version");
telegrammer::DatabaseLock first_lock(database_path);
bool second_lock_rejected = false;
try
{
telegrammer::DatabaseLock second_lock(database_path);
}
catch(const std::exception&)
{
second_lock_rejected = true;
}
require(second_lock_rejected, "database lock was not exclusive");
telegrammer::KeyStore keys(database_path);
auto token = keys.addKey("test-client");
require(token.has_value(), "key generation failed");
require(token->size() == 64, "generated key has the wrong length");
auto identity = keys.authenticate(*token);
require(identity.has_value() && identity->has_value(),
"generated key did not authenticate");
require(identity->value().name == "test-client", "wrong key identity");
auto listed_keys = keys.listKeys();
require(listed_keys.has_value() && listed_keys->size() == 1,
"key listing failed");
require(listed_keys->at(0).name == "test-client",
"key listing returned the wrong name");
telegrammer::SubscriptionStore subscriptions(database_path);
auto subscription = subscriptions.add(identity->value().id, 123,
"http://127.0.0.1/hook");
require(subscription.has_value(), "subscription creation failed");
auto duplicate = subscriptions.add(identity->value().id, 123,
"http://127.0.0.1/hook");
require(duplicate.has_value(), "duplicate subscription failed");
require(duplicate->id == subscription->id,
"duplicate subscription was not idempotent");
telegrammer::DeliveryStore deliveries(database_path);
require(deliveries.ensureBot(77).has_value(), "bot binding failed");
telegrammer::json updates = telegrammer::json::array({telegrammer::json{
{"update_id", 1},
{"message", {
{"message_id", 5},
{"from", {{"username", "Test_User"}}},
{"chat", {{"id", 123}, {"type", "private"}}},
{"text", "hello"}
}}}});
require(deliveries.ingest(77, updates).has_value(),
"update ingestion failed");
auto resolved = deliveries.resolveUsername("test_user");
require(resolved.has_value() && resolved->has_value(),
"username was not observed");
require(resolved->value() == 123, "username resolved to the wrong chat");
auto job = deliveries.claimNext();
require(job.has_value() && job->has_value(), "delivery was not queued");
require(job->value().subscription_id == subscription->id,
"delivery has the wrong subscription");
require(job->value().payload.find("hello") != std::string::npos,
"delivery payload was not preserved");
require(deliveries.complete(job->value().id).has_value(),
"delivery completion failed");
auto failed_update = telegrammer::json::array({telegrammer::json{
{"update_id", 2},
{"message", {
{"message_id", 6},
{"chat", {{"id", 123}, {"type", "private"}}},
{"text", "retry me"}
}}}});
require(deliveries.ingest(77, failed_update).has_value(),
"second update ingestion failed");
auto failed_job = deliveries.claimNext();
require(failed_job.has_value() && failed_job->has_value(),
"second delivery was not queued");
require(deliveries.fail(failed_job->value().id, "HTTP 400", false,
std::nullopt)
.has_value(),
"dead-letter transition failed");
auto dead_jobs = deliveries.listDead();
require(dead_jobs.has_value() && dead_jobs->size() == 1,
"dead-letter listing failed");
require(dead_jobs->at(0).last_error == "HTTP 400",
"dead-letter reason was not retained");
require(deliveries.retry(failed_job->value().id).has_value(),
"dead-letter retry command failed");
auto retried_job = deliveries.claimNext();
require(retried_job.has_value() && retried_job->has_value(),
"retried delivery was not queued");
require(deliveries.complete(retried_job->value().id).has_value(),
"retried delivery completion failed");
auto legacy_path = (directory / "legacy.db").string();
auto legacy_connection = mw::SQLite::connectFile(legacy_path);
require(legacy_connection.has_value(), "legacy database could not be made");
require((*legacy_connection)
->execute("CREATE TABLE api_keys (name TEXT PRIMARY KEY, "
"key TEXT NOT NULL, created_at INTEGER);")
.has_value(),
"legacy schema could not be made");
auto legacy_initialization =
telegrammer::initializeDatabase(legacy_path);
require(!legacy_initialization.has_value(),
"legacy schema was accepted");
const telegrammer::ServiceError* schema_error =
telegrammer::asServiceError(legacy_initialization.error());
require(schema_error != nullptr && schema_error->code == "DATABASE_SCHEMA",
"legacy schema returned the wrong error");
FakeTelegram telegram;
telegrammer::RuntimeState state;
state.polling_ready = true;
state.degraded = false;
int port = freePort();
telegrammer::ApiServer server(
{"127.0.0.1", port}, keys, subscriptions, deliveries, telegram, state);
require(server.start().has_value(), "API server did not start");
httplib::Client client("127.0.0.1", port);
client.set_connection_timeout(std::chrono::seconds(2));
auto unauthorized = client.Get("/health");
requireJsonResponse(unauthorized, 401, "missing auth was not rejected");
require(unauthorized->get_header_value("WWW-Authenticate") == "Bearer",
"auth challenge was missing");
auto auth_header = httplib::Headers{
{"Authorization", "Bearer " + *token}};
auto health = client.Get("/health", auth_header);
requireJsonResponse(health, 200, "health endpoint failed");
require(telegrammer::json::parse(health->body)["ok"] == true,
"health response was not an object success envelope");
telegrammer::ApiServer conflicting_server(
{"127.0.0.1", port}, keys, subscriptions, deliveries, telegram, state);
require(!conflicting_server.start().has_value(),
"occupied port was reported as available");
auto wrong_method = client.Post("/health", auth_header, "{}",
"application/json");
requireJsonResponse(wrong_method, 405,
"unsupported method did not return 405");
require(wrong_method->get_header_value("Allow") == "GET",
"unsupported method did not return Allow");
auto bad_media = client.Post("/subscribe", auth_header, "{}",
"text/plain");
requireJsonResponse(bad_media, 415, "media type validation failed");
auto subscribe_response = client.Post(
"/subscribe", auth_header,
R"({"chat_id":123,"callback_url":"http://127.0.0.1/hook"})",
"application/json");
requireJsonResponse(subscribe_response, 200, "HTTP subscription failed");
auto subscribe_body = telegrammer::json::parse(subscribe_response->body);
require(subscribe_body.is_object() && subscribe_body["ok"] == true,
"subscription response was not a JSON object");
int64_t subscription_id = subscribe_body["subscription_id"].get<int64_t>();
auto duplicate_response = client.Post(
"/subscribe", auth_header,
R"({"chat_id":123,"callback_url":"http://127.0.0.1/hook"})",
"application/json");
requireJsonResponse(duplicate_response, 200,
"duplicate HTTP subscription failed");
require(telegrammer::json::parse(duplicate_response->body)
["subscription_id"] == subscription_id,
"duplicate HTTP subscription was not idempotent");
auto send_response = client.Post(
"/send", auth_header,
R"({"chat_id":123,"text":"hello from api"})", "application/json");
requireJsonResponse(send_response, 200, "HTTP send failed");
require(telegram.sent_chat_id == 123 && telegram.sent_text == "hello from api",
"HTTP send did not reach Telegram client");
auto both_destinations = client.Post(
"/send", auth_header,
R"({"chat_id":123,"username":"alice","text":"bad"})",
"application/json");
requireJsonResponse(both_destinations, 400,
"ambiguous send destination was accepted");
auto subscriptions_response = client.Get("/subscriptions", auth_header);
requireJsonResponse(subscriptions_response, 200,
"subscription listing failed");
require(telegrammer::json::parse(subscriptions_response->body)
["subscriptions"].is_array(),
"subscription listing was not an array");
auto malformed_delete = client.Delete(
std::format("/subscriptions/{}junk", subscription_id), auth_header);
requireJsonResponse(malformed_delete, 400,
"malformed subscription ID was accepted");
auto delete_response = client.Delete(
std::format("/subscriptions/{}", subscription_id), auth_header);
requireJsonResponse(delete_response, 204, "subscription deletion failed");
server.stop();
server.wait();
auto removed = subscriptions.remove(identity->value().id,
subscription->id);
require(removed.has_value() && !*removed,
"HTTP subscription deletion was not persisted");
auto deleted_key = keys.deleteKey("test-client");
require(deleted_key.has_value() && *deleted_key, "key deletion failed");
auto revoked = keys.authenticate(*token);
require(revoked.has_value() && !revoked->has_value(),
"deleted key still authenticated");
std::filesystem::remove_all(directory);
return 0;
}
catch(const std::exception& error)
{
std::filesystem::remove_all(directory);
std::cerr << error.what() << std::endl;
return 1;
}
}