Add unit tests

This commit is contained in:
Ramon Caballero 2026-04-06 22:49:51 +01:00
parent 7cd237485a
commit 1cbe008a86
13 changed files with 1966 additions and 23 deletions

3
.gitignore vendored
View File

@ -4,3 +4,6 @@
# Composer files:
/vendor/
# PHPUnit files:
.phpunit.result.cache

View File

@ -11,4 +11,4 @@ $config = new Config(__DIR__ . "/../config/pb-dydns.json");
$logger = new Logger(__DIR__ . "/../logs/pb-dydns.log");
$cmd = new UpdateDnsCommand($config, $logger);
$cmd->run($argv);
exit($cmd->run($argv));

View File

@ -9,5 +9,8 @@
"App\\": "src/"
}
},
"require": {}
"require-dev":
{
"phpunit/phpunit": "^12.5"
}
}

1676
composer.lock generated

File diff suppressed because it is too large Load Diff

8
phpunit.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true" verbose="true" backupGlobals="false" backupStaticAttributes="false">
<testsuites>
<testsuite name="pb-dydns Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@ -9,6 +9,10 @@ use App\Util\Console;
class UpdateDnsCommand
{
public const STATUS_NOTHING_TO_CHANGE = 0;
public const STATUS_UPDATED = 1;
public const STATUS_ERROR = 2;
private Config $config;
private Logger $logger;
@ -18,61 +22,69 @@ class UpdateDnsCommand
$this->logger = $logger;
}
public function run(array $argv): void
public function run(array $argv, ?PorkbunAPI $api = null, ?string $myIp = null): int
{
if (count($argv) < 2)
{
echo "Usage: pb-dydns.php <domain>\n";
exit(1);
return self::STATUS_ERROR;
}
$domain = $argv[1];
$api = new PorkbunAPI($this->config->get('config_filename'));
// Allow API injection for tests:
$api = $api ?? new PorkbunAPI($this->config->get('config_filename'));
// Test connection to Porkbun API in order to get the public IP:
// 1. Test connection to Porkbun API in order to get the public IP:
$raw = $api->ping();
$result = json_decode($raw);
if ($result === null)
{
Console::echo("Invalid JSON returned by Porkbun API:\n$raw\n");
exit(3);
return self::STATUS_ERROR;
}
if (!isset($result->status))
{
Console::echo("Porkbun API response missing 'status' field:\n$raw\n");
exit(3);
return self::STATUS_ERROR;
}
if ($result->status !== "SUCCESS")
{
Console::echo("Porkbun API returned an error:\n$raw\n");
exit(3);
return self::STATUS_ERROR;
}
if (!isset($result->yourIp))
{
Console::echo("Porkbun API did not return your public IP.\n");
exit(3);
return self::STATUS_ERROR;
}
$myIp = $result->yourIp;
$myIp = $myIp ?? $result->yourIp;
Console::echo("Your public IP address is $myIp" . PHP_EOL);
// Retrieve all DNS records associated with user's domain:
// 2. Retrieve all DNS records associated with user's domain:
$records = $api->retrieve($domain);
$data = json_decode($records);
if ($data === null || !isset($data->records))
{
Console::echo("Invalid DNS records returned by Porkbun API:\n$records\n");
return self::STATUS_ERROR;
}
// Discard those records that are not type "A":
$data = json_decode($records);
$filteredRecords = array_filter($data->records, function ($record)
{
return $record->type == "A";
return isset($record->type) && $record->type == "A";
});
// Update the records that passed the filter with the public IP:
// 3. Update the records that passed the filter with the public IP:
$updated = false;
foreach ($filteredRecords as $record)
{
Console::echo("Porkbun's DNS for $record->name is pointing to $record->content... ");
@ -84,12 +96,17 @@ class UpdateDnsCommand
$name = rtrim(strstr($record->name, $domain, true), ".");
$result = json_decode($api->edit($domain, $record->id, $myIp, $name));
if ($result->status == "SUCCESS")
if ($result && isset($result->status) && $result->status === "SUCCESS")
{
Console::echo("Done!" . PHP_EOL);
Console::echo("Done!\n");
$this->logger->log("Updated DNS on $record->name from $record->content to $myIp");
$updated = true;
}
else
{
Console::echo("Failed!\n");
return self::STATUS_ERROR;
}
$this->logger->log("Updated DNS on $record->name from $record->content to $myIp");
}
else
@ -99,5 +116,7 @@ class UpdateDnsCommand
Console::echo("");
}
return $updated ? self::STATUS_UPDATED : self::STATUS_NOTHING_TO_CHANGE;
}
}

View File

@ -16,12 +16,19 @@ class Config
}
$json = file_get_contents($filename);
$this->data = json_decode($json, true);
$data = json_decode($json, true);
if ($this->data === null)
if (json_last_error() !== JSON_ERROR_NONE)
{
throw new \RuntimeException("Invalid JSON in config file: " . json_last_error_msg());
}
if ($data === null)
{
throw new RuntimeException("Invalid JSON in config file: $filename");
}
$this->data = $data;
}
public function get(string $key): mixed

View File

View File

View File

@ -0,0 +1,101 @@
<?php
use PHPUnit\Framework\TestCase;
use App\Api\PorkbunAPI;
class PorkbunAPITest extends TestCase
{
private string $tmpConfig;
protected function setUp(): void
{
$this->tmpConfig = tempnam(sys_get_temp_dir(), 'cfg_');
file_put_contents($this->tmpConfig, json_encode([
"url" => "https://example.com/api/",
"apikey" => "TESTKEY",
"secretapikey" => "TESTSECRET"
]));
}
public function testPingBuildsCorrectEndpointAndReturnsValue()
{
$api = $this->getMockBuilder(PorkbunAPI::class)
->setConstructorArgs([$this->tmpConfig])
->onlyMethods(['ping'])
->getMock();
$api->expects($this->once())
->method('ping')
->willReturn('{"status":"SUCCESS"}');
$this->assertSame('{"status":"SUCCESS"}', $api->ping());
}
public function testCreateReturnsExpectedValue()
{
$api = $this->getMockBuilder(PorkbunAPI::class)
->setConstructorArgs([$this->tmpConfig])
->onlyMethods(['create'])
->getMock();
$api->expects($this->once())
->method('create')
->with("example.com", "www", "A", "1.2.3.4", 600)
->willReturn('{"status":"CREATED"}');
$result = $api->create("example.com", "www", "A", "1.2.3.4", 600);
$this->assertSame('{"status":"CREATED"}', $result);
}
public function testEditReturnsExpectedValue()
{
$api = $this->getMockBuilder(PorkbunAPI::class)
->setConstructorArgs([$this->tmpConfig])
->onlyMethods(['edit'])
->getMock();
$api->expects($this->once())
->method('edit')
->with("example.com", "123", "5.6.7.8", "www")
->willReturn('{"status":"EDITED"}');
$result = $api->edit("example.com", "123", "5.6.7.8", "www");
$this->assertSame('{"status":"EDITED"}', $result);
}
public function testRetrieveReturnsExpectedValue()
{
$api = $this->getMockBuilder(PorkbunAPI::class)
->setConstructorArgs([$this->tmpConfig])
->onlyMethods(['retrieve'])
->getMock();
$api->expects($this->once())
->method('retrieve')
->with("example.com", null)
->willReturn('{"records":[1,2,3]}');
$result = $api->retrieve("example.com");
$this->assertSame('{"records":[1,2,3]}', $result);
}
public function testRetrieveByNameTypeReturnsExpectedValue()
{
$api = $this->getMockBuilder(PorkbunAPI::class)
->setConstructorArgs([$this->tmpConfig])
->onlyMethods(['retrieveByNameType'])
->getMock();
$api->expects($this->once())
->method('retrieveByNameType')
->with("example.com", "A", "www")
->willReturn('{"records":["A","B"]}');
$result = $api->retrieveByNameType("example.com", "A", "www");
$this->assertSame('{"records":["A","B"]}', $result);
}
}

View File

@ -0,0 +1,79 @@
<?php
use PHPUnit\Framework\TestCase;
use App\Api\PorkbunAPI;
use App\Command\UpdateDnsCommand;
use App\Config\Config;
use App\Util\Logger;
class UpdateDnsCommandTest extends TestCase
{
private $config;
private $logger;
protected function setUp(): void
{
$this->config = $this->createMock(Config::class);
$this->logger = $this->createMock(Logger::class);
}
// Scenario: DNS already matches public IP, so no update should occur:
public function testNoChangesNeeded()
{
$api = $this->createMock(PorkbunAPI::class);
// ping() must return yourIp or the command returns STATUS_ERROR immediately:
$api->method('ping')->willReturn('{"status":"SUCCESS","yourIp":"1.2.3.4"}');
// retrieve() must return JSON with a records array, otherwise json_decode() fails:
$api->method('retrieve')->willReturn(json_encode(
[
"records" => [
[
"name" => "test.example.com",
"type" => "A",
"content" => "1.2.3.4",
"id" => "123"
]
]
]));
$cmd = new UpdateDnsCommand($this->config, $this->logger);
$status = $cmd->run(["script.php", "example.com"], $api, "1.2.3.4");
$this->assertSame(UpdateDnsCommand::STATUS_NOTHING_TO_CHANGE, $status);
}
// Scenario: DNS is outdated, so edit() should be called and STATUS_UPDATED returned:
public function testDnsIsUpdated()
{
$api = $this->createMock(PorkbunAPI::class);
// ping() must return yourIp or the command returns STATUS_ERROR immediately:
$api->method('ping')->willReturn('{"status":"SUCCESS","yourIp":"1.2.3.4"}');
// retrieve() must return JSON with a records array, otherwise json_decode() fails:
$api->method('retrieve')->willReturn(json_encode(
[
"records" => [
[
"name" => "test.example.com",
"type" => "A",
"content" => "5.6.7.8",
"id" => "123"
]
]
]));
// edit() must return a SUCCESS status or the command returns STATUS_ERROR:
$api->method('edit')->willReturn(json_encode(["status" => "SUCCESS"]));
// When DNS is updated, the command must record the change in the log exactly once:
$this->logger->expects($this->once())->method('log')->with($this->stringContains("Updated DNS"));
$cmd = new UpdateDnsCommand($this->config, $this->logger);
$status = $cmd->run(["script.php", "example.com"], $api, "1.2.3.4");
$this->assertSame(UpdateDnsCommand::STATUS_UPDATED, $status);
}
}

View File

@ -0,0 +1,32 @@
<?php
use PHPUnit\Framework\TestCase;
use App\Config\Config;
class ConfigTest extends TestCase
{
public function testLoadsValidJson()
{
$tmp = tempnam(sys_get_temp_dir(), 'cfg_');
file_put_contents($tmp, json_encode(["foo" => "bar"]));
$config = new Config($tmp);
$this->assertSame("bar", $config->get("foo"));
}
public function testThrowsOnMissingFile()
{
$this->expectException(RuntimeException::class);
new Config("/nonexistent/file.json");
}
public function testThrowsOnInvalidJson()
{
$tmp = tempnam(sys_get_temp_dir(), 'cfg_');
file_put_contents($tmp, "{invalid json");
$this->expectException(RuntimeException::class);
new Config($tmp);
}
}

19
tests/Util/LoggerTest.php Normal file
View File

@ -0,0 +1,19 @@
<?php
use PHPUnit\Framework\TestCase;
use App\Util\Logger;
class LoggerTest extends TestCase
{
public function testLogWritesMessage()
{
$tmp = tempnam(sys_get_temp_dir(), 'log_');
$logger = new Logger($tmp);
$logger->log("Hello world");
$contents = file_get_contents($tmp);
$this->assertStringContainsString("Hello world", $contents);
}
}