Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/pod.git

Adds unit tests

AuthorDave Jarvis <email>
Date2026-01-25 12:12:53 GMT-0800
Commit07fd594f4814c099082b57152a06e4011947d260
Parent56c00c5
Address.php
];
+ /**
+ * Initializes the address object with raw input data.
+ *
+ * @param array $input Raw address field data from form submission.
+ */
public function __construct( array $input ) {
$this->fields = $input;
- }
-
- private function escape( $value ) {
- return htmlspecialchars( $value ?? '', ENT_QUOTES, 'UTF-8' );
}
+ /**
+ * Validates required fields, normalizes data, and converts state names
+ * to abbreviations.
+ *
+ * @param array $required Field names that must be present.
+ *
+ * @return bool True if all required fields are present, false otherwise.
+ */
public function process( array $required ) {
foreach( $required as $field ) {
$clean = [];
+
foreach( $rules as $field => $config ) {
$value = mb_substr( trim( $this->fields[ $field ] ?? '' ), 0,
$clean[ $field ] = $finalValue;
}
+
$this->fields = $clean;
return empty( $this->missing );
}
+ /**
+ * Saves the validated address to the session if all required fields
+ * are present.
+ *
+ * @param Session $session Session object to store the address data.
+ */
public function export( Session $session ) {
if( empty( $this->missing ) ) {
$session->write( 'user_address', $this->fields );
}
}
+ /**
+ * Populates the shipping address section of the API payload with
+ * address fields.
+ *
+ * @param array &$payload API request payload to modify by reference.
+ *
+ * @return string The country code from the address fields.
+ */
public function configure( array &$payload ) {
$payload[ 'shipping_address' ] = [
'street2' => $this->fields[ 'street2' ] ?? ''
];
+
return $this->fields[ 'country' ] ?? '';
}
+ /**
+ * Outputs an HTML list of missing required fields for display to the
+ * user.
+ */
public function render() {
- if( !empty( $this->missing ) ) {
+ if( !$this->isComplete() ) {
echo "<h2>Missing fields</h2><ul>";
+
foreach( $this->missing as $field ) {
echo "<li>" . $this->escape( $field ) . "</li>";
}
+
echo "</ul>";
}
+ }
+
+ /**
+ * Checks if a specific field matches an expected value.
+ *
+ * @param string $field The field name to check.
+ * @param string $value The expected value to compare against.
+ *
+ * @return bool True if the field value matches the expected value.
+ */
+ public function isField( $field, $value ) {
+ return ($this->fields[ $field ] ?? '') === $value;
+ }
+
+ /**
+ * Checks if all required fields are present and the address is complete.
+ *
+ * @return bool True if there are no missing required fields.
+ */
+ public function isComplete() {
+ return empty( $this->missing );
+ }
+
+ private function escape( $value ) {
+ return htmlspecialchars( $value ?? '', ENT_QUOTES, 'UTF-8' );
}
}
Configuration.php
private $settings = [];
+ /**
+ * Loads configuration settings from the lulu.config file in the
+ * user's home directory.
+ */
public function load() {
$path = $this->homeDirectory() . '/.keys/lulu.config';
+
if( file_exists( $path ) ) {
$this->settings = json_decode( file_get_contents( $path ), true );
}
+ }
+
+ /**
+ * Initializes the publisher with API credentials and order
+ * specifications from the loaded configuration.
+ *
+ * @param Publisher $publisher Publisher object to configure.
+ */
+ public function configure( Publisher $publisher ) {
+ $publisher->initialize(
+ $this->settings[ 'CLIENT_KEY' ] ?? '',
+ $this->settings[ 'URL_BASE' ] ?? '',
+ [
+ 'package' => $this->settings[ 'ORDER_PACKAGE' ] ?? '',
+ 'pages' => $this->settings[ 'ORDER_PAGE_COUNT' ] ?? 0,
+ 'quantity' => $this->settings[ 'ORDER_QUANTITY' ] ?? 1,
+ 'shipping' => $this->settings[ 'ORDER_SHIPPING' ] ?? ''
+ ]
+ );
}
return $result;
- }
-
- public function configure( Publisher $publisher ) {
- $publisher->initialize(
- $this->settings[ 'CLIENT_KEY' ] ?? '',
- $this->settings[ 'URL_BASE' ] ?? '',
- [
- 'package' => $this->settings[ 'ORDER_PACKAGE' ] ?? '',
- 'pages' => $this->settings[ 'ORDER_PAGE_COUNT' ] ?? 0,
- 'quantity' => $this->settings[ 'ORDER_QUANTITY' ] ?? 1,
- 'shipping' => $this->settings[ 'ORDER_SHIPPING' ] ?? ''
- ]
- );
}
}
Currency.php
+<?php
+enum Currency: string {
+ case CAD = 'CAD';
+ case USD = 'USD';
+ case AUD = 'AUD';
+ case EUR = 'EUR';
+ case GBP = 'GBP';
+
+ public function replace( string $text, string $placeholder ): string {
+ return str_replace( $placeholder, $this->value, $text );
+ }
+}
ExchangeRate.php
+<?php
+class ExchangeRate {
+ private array $rates = [];
+ private string $base;
+
+ // How long until we refresh the exchange rate cache (in minutes).
+ private const CACHE_LIFETIME = 14400;
+
+ public function __construct( Currency $base, string $url, string $cache ) {
+ $url = $base->replace( $url, '{{currency}}' );
+
+ if( $this->expired( $cache ) ) {
+ $this->fetch( $url, $cache );
+ } else {
+ $this->fetch( $cache, $cache );
+ }
+ }
+
+ public function convert( Money $money, Currency $targetCurrency ): Money {
+ return $money->convert( $this, $targetCurrency );
+ }
+
+ public function rate( Currency $source, Currency $target ): float {
+ $sourceRate = $this->rateFor( $source );
+ $targetRate = $this->rateFor( $target );
+
+ return $targetRate / $sourceRate;
+ }
+
+ private function expired( string $cache ): bool {
+ return !file_exists( $cache )
+ || ( time() - filemtime( $cache ) ) > self::CACHE_LIFETIME;
+ }
+
+ private function fetch( string $source, string $cache ): void {
+ $response = @file_get_contents( $source );
+
+ if( is_string( $response ) ) {
+ $payload = json_decode( $response, true );
+
+ if( isset( $payload['rates'] ) && isset( $payload['base'] ) ) {
+ $this->parse( $payload );
+
+ if( $source !== $cache ) {
+ file_put_contents( $cache, json_encode( $payload ) );
+ }
+ }
+ }
+ }
+
+ private function parse( array $payload ): void {
+ $this->rates = $payload['rates'];
+ $this->base = $payload['base'];
+ }
+
+ private function rateFor( Currency $currency ): float {
+ $code = $currency->value;
+
+ if( $code === $this->base ) {
+ return 1.0;
+ }
+
+ return $this->rates[$code]
+ ?? $this->rateFor( Currency::from( $this->base ) );
+ }
+}
Money.php
+<?php
+class Money {
+ private float $amount;
+ private Currency $currency;
+
+ public function __construct( float $amount, Currency $currency ) {
+ $this->amount = $amount;
+ $this->currency = $currency;
+ }
+
+ public function convert( ExchangeRate $forex, Currency $target ): Money {
+ $rate = $forex->rate( $this->currency, $target );
+ return new Money( $this->amount * $rate, $target );
+ }
+
+ public function render(): string {
+ return sprintf(
+ '<span class="price">%.2f %s</span>',
+ $this->amount,
+ $this->currency->value
+ );
+ }
+
+ public function isCurrency( Currency $currency ): bool {
+ return $this->currency === $currency;
+ }
+
+ public function isAmount( float $amount ): bool {
+ return abs( $this->amount - $amount ) < 0.01;
+ }
+}
Publisher.php
];
+ /**
+ * Sets up the publisher with API credentials and order
+ * specifications.
+ *
+ * @param string $key Client authentication key for API access.
+ * @param string $base Base URL for the API endpoints.
+ * @param array $specs Order specifications including package, page
+ * count, quantity, and shipping method.
+ */
public function initialize( string $key, string $base, array $specs ) {
$this->key = $key;
$this->base = $base;
$this->specs = $specs;
}
+ /**
+ * Authenticates with the API, builds the cost calculation request,
+ * and processes the response through the order object.
+ *
+ * @param Address $address Address object containing shipping details.
+ * @param Order $order Order object to receive and render the result.
+ */
public function process( Address $address, Order $order ) {
$token = $this->authenticate();
}
}
+
tests/Main.php
+<?php
+require_once 'TestSuite.php';
+require_once 'TestAddress.php';
+require_once 'TestExchangeRate.php';
+
+class Main {
+ /**
+ * Executes all registered test classes.
+ */
+ public function run() {
+ $suite = new TestSuite();
+ $tests = [ new AddressTest(), new ExchangeRateTest() ];
+
+ foreach( $tests as $test ) {
+ $test->run( fn( $methods ) => $suite->execute( $methods, $test ) );
+ }
+ }
+}
+
+$main = new Main();
+$main->run();
tests/TestAddress.php
+<?php
+require_once '../Address.php';
+
+class AddressTest {
+ public function run( $callback ) {
+ $callback( [
+ 'test_Process_MissingFields_Incomplete',
+ 'test_Process_AllFields_Normalized'
+ ] );
+ }
+
+ public function test_Process_MissingFields_Incomplete() {
+ $addr = new Address( [ 'name' => 'John' ] );
+ $addr->process( [ 'name', 'street1', 'city' ] );
+
+ return !$addr->isComplete();
+ }
+
+ public function test_Process_AllFields_Normalized() {
+ $addr = new Address( [
+ 'name' => 'John Doe',
+ 'street1' => '123 main st',
+ 'city' => 'toronto',
+ 'state' => 'Ontario',
+ 'postcode' => 'm5h 2n2',
+ 'country' => 'ca',
+ 'phone' => '555-1234',
+ 'email' => 'john@example.com'
+ ] );
+ $addr->process( [ 'name', 'street1', 'city', 'state' ] );
+
+ return $addr->isComplete() &&
+ $addr->isField( 'street1', '123 MAIN ST' ) &&
+ $addr->isField( 'city', 'TORONTO' ) &&
+ $addr->isField( 'state', 'ON' ) &&
+ $addr->isField( 'postcode', 'M5H 2N2' ) &&
+ $addr->isField( 'country', 'CA' );
+ }
+}
tests/TestExchangeRate.php
+<?php
+require_once '../ExchangeRate.php';
+require_once '../Money.php';
+require_once '../Currency.php';
+
+class ExchangeRateTest {
+ private string $cacheFile;
+
+ public function __construct() {
+ $this->cacheFile = sys_get_temp_dir() . '/rates.json';
+ }
+
+ /**
+ * Executes all test methods via callback.
+ *
+ * @param callable $callback Function to execute test methods
+ */
+ public function run( $callback ) {
+ $this->cleanCache();
+
+ $callback( [
+ 'test_Convert_SameCurrency_ReturnsSameAmount',
+ 'test_Convert_RoundTrip_ReturnsOriginalAmount'
+ ] );
+ }
+
+ /**
+ * Tests conversion when source and target currencies are identical.
+ *
+ * @return bool True if conversion returns same amount.
+ */
+ public function test_Convert_SameCurrency_ReturnsSameAmount() {
+ $forex = $this->createExchangeRate();
+ $amount = $this->createAmount();
+ $money = new Money( $amount, Currency::CAD );
+ $converted = $money->convert( $forex, Currency::CAD );
+
+ return $converted->isCurrency( Currency::CAD ) &&
+ $converted->isAmount( $amount );
+ }
+
+ /**
+ * Tests bidirectional conversion symmetry.
+ *
+ * @return bool True if forward and reverse conversions are consistent.
+ */
+ public function test_Convert_RoundTrip_ReturnsOriginalAmount() {
+ $forex = $this->createExchangeRate();
+ $amount = $this->createAmount();
+ $original = new Money( $amount, Currency::GBP );
+ $toAUD = $original->convert( $forex, Currency::AUD );
+ $backToGBP = $toAUD->convert( $forex, Currency::GBP );
+
+ return $toAUD->isCurrency( Currency::AUD ) &&
+ !$toAUD->isAmount( $amount ) &&
+ $backToGBP->isCurrency( Currency::GBP ) &&
+ $backToGBP->isAmount( $amount );
+ }
+
+ /**
+ * Creates a random amount between 0 and 10000.00 inclusive.
+ *
+ * @return float Random amount with 2 decimal precision.
+ */
+ private function createAmount() {
+ return mt_rand( 0, 1000000 ) / 100.0;
+ }
+
+ /**
+ * Creates an ExchangeRate instance for testing.
+ *
+ * @return ExchangeRate Configured exchange rate instance.
+ */
+ private function createExchangeRate(): ExchangeRate {
+ return new ExchangeRate(
+ Currency::USD,
+ 'https://api.frankfurter.dev/v1/latest?base={{currency}}',
+ $this->cacheFile
+ );
+ }
+
+ /**
+ * Deletes the cache file to force fresh data download.
+ */
+ private function cleanCache(): void {
+ if( file_exists( $this->cacheFile ) ) {
+ unlink( $this->cacheFile );
+ }
+ }
+}
tests/TestSuite.php
+<?php
+class TestSuite {
+ public function execute( $tests, $test ) {
+ foreach( $tests as $method ) {
+ $result = $test->$method();
+ echo "{$method}: " . ($result ? "PASS" : "FAIL") . "\n";
+ }
+ }
+}
Delta371 lines added, 18 lines removed, 353-line increase