| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-01-25 12:12:53 GMT-0800 |
| Commit | 07fd594f4814c099082b57152a06e4011947d260 |
| Parent | 56c00c5 |
| ]; | ||
| + /** | ||
| + * 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' ); | ||
| } | ||
| } | ||
| 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' ] ?? '' | ||
| - ] | ||
| - ); | ||
| } | ||
| } | ||
| +<?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 ); | ||
| + } | ||
| +} | ||
| +<?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 ) ); | ||
| + } | ||
| +} | ||
| +<?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; | ||
| + } | ||
| +} | ||
| ]; | ||
| + /** | ||
| + * 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(); | ||
| } | ||
| } | ||
| + | ||
| +<?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(); | ||
| +<?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' ); | ||
| + } | ||
| +} | ||
| +<?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 ); | ||
| + } | ||
| + } | ||
| +} | ||
| +<?php | ||
| +class TestSuite { | ||
| + public function execute( $tests, $test ) { | ||
| + foreach( $tests as $method ) { | ||
| + $result = $test->$method(); | ||
| + echo "{$method}: " . ($result ? "PASS" : "FAIL") . "\n"; | ||
| + } | ||
| + } | ||
| +} | ||
| Delta | 371 lines added, 18 lines removed, 353-line increase |
|---|