| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-01-25 20:13:02 GMT-0800 |
| Commit | 93d6ee2480434d5d4a78982e9ee25c2f43d6679f |
| Parent | 2c48ab6 |
| -openapi_public.yml | ||
| +api.yml | ||
| private const REGION_MAP = [ | ||
| + // Australia | ||
| + 'NEWSOUTHWALES' => 'NSW', | ||
| + 'VICTORIA' => 'VIC', | ||
| + 'QUEENSLAND' => 'QLD', | ||
| + 'WESTERNAUSTRALIA' => 'WA', | ||
| + 'SOUTHAUSTRALIA' => 'SA', | ||
| + 'TASMANIA' => 'TAS', | ||
| + 'AUSTRALIANCAPITALTERRITORY' => 'ACT', | ||
| + 'NORTHERNTERRITORY' => 'NT', | ||
| + // Canada | ||
| 'ALBERTA' => 'AB', | ||
| 'BRITISHCOLUMBIA' => 'BC', | ||
| 'SASKATCHEWAN' => 'SK', | ||
| 'YUKON' => 'YT', | ||
| + // United States | ||
| 'ALABAMA' => 'AL', | ||
| 'ALASKA' => 'AK', | ||
| 'ARIZONA' => 'AZ', | ||
| 'ARKANSAS' => 'AR', | ||
| 'CALIFORNIA' => 'CA', | ||
| 'COLORADO' => 'CO', | ||
| 'CONNECTICUT' => 'CT', | ||
| 'DELAWARE' => 'DE', | ||
| + 'DISTRICTOFCOLUMBIA' => 'DC', | ||
| 'FLORIDA' => 'FL', | ||
| 'GEORGIA' => 'GA', | ||
| 'WESTVIRGINIA' => 'WV', | ||
| 'WISCONSIN' => 'WI', | ||
| - 'WYOMING' => 'WY', | ||
| - 'DISTRICTOFCOLUMBIA' => 'DC' | ||
| + 'WYOMING' => 'WY' | ||
| ]; | ||
| /** | ||
| - * Initializes the address object with raw input data. | ||
| + * Initializes and automatically validates the address. | ||
| * | ||
| - * @param array $input Raw address field data from form submission. | ||
| + * @param array $input Raw address field data. | ||
| */ | ||
| public function __construct( array $input ) { | ||
| $this->fields = $input; | ||
| + $this->process(); | ||
| } | ||
| * @return bool True if all required fields are present, false otherwise. | ||
| */ | ||
| - public function process( array $required ) { | ||
| + public function process( array $required = [] ): bool { | ||
| foreach( $required as $field ) { | ||
| if( empty( $this->fields[ $field ] ) ) { | ||
| private $summary = []; | ||
| private $errorMessage = ''; | ||
| - private $statusCode = 0; | ||
| + private $renderMethod; | ||
| public function process( array $response, int $statusCode ) { | ||
| - $this->statusCode = $statusCode; | ||
| - | ||
| if( $statusCode === 201 ) { | ||
| - $this->summary = $response; | ||
| + $this->processSuccess( $response ); | ||
| } else { | ||
| - $this->errorMessage = $response[ 'shipping_address' ][ 'detail' ] | ||
| - [ 'errors' ][ 0 ][ 'message' ] | ||
| - ?? $response[ 'detail' ] | ||
| - ?? "An unexpected error occurred (HTTP $statusCode)."; | ||
| + $this->processError( $response, $statusCode ); | ||
| } | ||
| + } | ||
| + | ||
| + protected function processSuccess( array $response ) { | ||
| + $this->summary = $response; | ||
| + $this->renderMethod = 'renderSummary'; | ||
| + } | ||
| + | ||
| + protected function processError( array $response, int $statusCode ) { | ||
| + $this->errorMessage = $response[ 'shipping_address' ][ 'detail' ] | ||
| + [ 'errors' ][ 0 ][ 'message' ] | ||
| + ?? $response[ 'detail' ] | ||
| + ?? "An unexpected error occurred (HTTP $statusCode)."; | ||
| + $this->renderMethod = 'renderError'; | ||
| } | ||
| public function render() { | ||
| - if( $this->statusCode === 201 ) { | ||
| - $this->renderSummary(); | ||
| - } else { | ||
| - $this->renderError(); | ||
| - } | ||
| + $this->{$this->renderMethod}(); | ||
| } | ||
| /** | ||
| - * Sets up the publisher with API credentials and order | ||
| - * specifications. | ||
| + * 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. | ||
| + * count, quantity, and shipping method. | ||
| */ | ||
| public function initialize( string $key, string $base, array $specs ) { | ||
| public function process( Address $address, Order $order ) { | ||
| $token = $this->authenticate(); | ||
| + $body = [ 'detail' => 'Authentication failed.' ]; | ||
| + $code = 401; | ||
| - if( !$token ) { | ||
| - $order->process( [ 'detail' => 'Authentication failed.' ], 401 ); | ||
| - return; | ||
| + if( $token ) { | ||
| + $payload = $this->buildPayload( $address ); | ||
| + $res = $this->request( self::PATH_COST, $payload, $token ); | ||
| + $body = $res['body']; | ||
| + $code = $res['code']; | ||
| + | ||
| + $this->infuse( $body, $payload, $code ); | ||
| } | ||
| + | ||
| + $order->process( $body, $code ); | ||
| + } | ||
| + /** | ||
| + * Builds the request payload from specifications and address. | ||
| + * | ||
| + * @param Address $address Address object to configure. | ||
| + * @return array The formatted API payload. | ||
| + */ | ||
| + private function buildPayload( Address $address ): array { | ||
| $payload = [ | ||
| 'line_items' => [ [ | ||
| 'page_count' => $this->specs[ 'pages' ], | ||
| 'quantity' => $this->specs[ 'quantity' ], | ||
| 'pod_package_id' => $this->specs[ 'package' ] | ||
| ] ], | ||
| 'shipping_option' => $this->specs[ 'shipping' ] | ||
| ]; | ||
| - | ||
| $country = $address->configure( $payload ); | ||
| $payload[ 'currency' ] = self::CURRENCY_MAP[ $country ] ?? 'USD'; | ||
| - $context = stream_context_create( [ 'http' => [ | ||
| - 'method' => 'POST', | ||
| - 'header' => "Authorization: Bearer $token\r\nContent-Type: " . | ||
| - "application/json\r\n", | ||
| - 'content' => json_encode( $payload ), | ||
| - 'ignore_errors' => true | ||
| - ] ] ); | ||
| + return $payload; | ||
| + } | ||
| - $raw = @file_get_contents( $this->base . self::PATH_COST, false, $context ); | ||
| - $body = json_decode( $raw, true ) ?? []; | ||
| + /** | ||
| + * Merges original address fields into the response. | ||
| + * | ||
| + * @param array $body Reference to the response body array. | ||
| + * @param array $payload The original request payload. | ||
| + * @param int $code The HTTP response code. | ||
| + */ | ||
| + private function infuse(array &$body, array $payload, int $code) { | ||
| + if( $code === 201 && | ||
| + isset( $body['shipping_address'], $payload['shipping_address'] ) ) { | ||
| + $address = &$body['shipping_address']; | ||
| + $sent = $payload['shipping_address']; | ||
| + | ||
| + $address['name'] = $address['name'] ?? $sent['name'] ?? ''; | ||
| + $address['country_code'] = $address['country_code'] ?? | ||
| + $sent['country_code'] ?? ''; | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Obtains an access token from the authentication endpoint. | ||
| + * | ||
| + * @return string|null The access token or null on failure. | ||
| + */ | ||
| + private function authenticate() { | ||
| + $header = "Authorization: Basic " . base64_encode( $this->key ) . | ||
| + "\r\nContent-Type: application/x-www-form-urlencoded\r\n"; | ||
| + $content = http_build_query( [ 'grant_type' => 'client_credentials' ] ); | ||
| + $res = $this->post( self::PATH_AUTH, $content, $header ); | ||
| + return $res['body'][ 'access_token' ] ?? null; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Sends the cost calculation request to the API. | ||
| + * | ||
| + * @param string $path The API endpoint path. | ||
| + * @param array $data The payload to send. | ||
| + * @param string $token The bearer token. | ||
| + * @return array Response body and HTTP status code. | ||
| + */ | ||
| + private function request( string $path, array $data, string $token ) { | ||
| + $header = "Authorization: Bearer $token\r\nContent-Type: " . | ||
| + "application/json\r\n"; | ||
| + return $this->post( $path, json_encode( $data ), $header ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Executes the HTTP POST request. | ||
| + * | ||
| + * @param string $path The API endpoint path. | ||
| + * @param string $content The raw request body. | ||
| + * @param string $header The HTTP headers. | ||
| + * @return array Decoded JSON body and status code. | ||
| + */ | ||
| + protected function post( string $path, string $content, string $header ) { | ||
| + $context = stream_context_create( [ | ||
| + 'http' => [ | ||
| + 'method' => 'POST', | ||
| + 'header' => $header, | ||
| + 'content' => $content, | ||
| + 'ignore_errors' => true | ||
| + ] | ||
| + ] ); | ||
| + $raw = @file_get_contents( $this->base . $path, false, $context ); | ||
| $code = 0; | ||
| if( !empty( $http_response_header ) ) { | ||
| preg_match( "/\d{3}/", $http_response_header[ 0 ], $matches ); | ||
| $code = intval( $matches[ 0 ] ); | ||
| - } | ||
| - | ||
| - // Preserve the name and country code for the order summary. | ||
| - if( $code === 201 && isset( $body[ 'shipping_address' ] ) ) { | ||
| - $sentName = $payload[ 'shipping_address' ][ 'name' ]; | ||
| - $sentCountry = $payload[ 'shipping_address' ][ 'country_code' ]; | ||
| - | ||
| - if( empty( $body[ 'shipping_address' ][ 'name' ] ) ) { | ||
| - $body[ 'shipping_address' ][ 'name' ] = $sentName; | ||
| - } | ||
| - if( empty( $body[ 'shipping_address' ][ 'country_code' ] ) ) { | ||
| - $body[ 'shipping_address' ][ 'country_code' ] = $sentCountry; | ||
| - } | ||
| } | ||
| - | ||
| - $order->process( $body, $code ); | ||
| - } | ||
| - | ||
| - private function authenticate() { | ||
| - $context = stream_context_create( [ 'http' => [ | ||
| - 'method' => 'POST', | ||
| - 'header' => "Authorization: Basic " . base64_encode( $this->key ) . | ||
| - "\r\nContent-Type: application/x-www-form-urlencoded\r\n", | ||
| - 'content' => http_build_query( [ 'grant_type' => 'client_credentials' ] ) | ||
| - ] ] ); | ||
| - $response = @file_get_contents( | ||
| - $this->base . self::PATH_AUTH, false, $context | ||
| - ); | ||
| - return $response | ||
| - ? json_decode( $response, true )[ 'access_token' ] ?? null | ||
| - : null; | ||
| + return [ 'body' => json_decode( $raw, true ) ?? [], 'code' => $code ]; | ||
| } | ||
| } | ||
| <?php | ||
| -require_once 'TestSuite.php'; | ||
| +require_once 'TestSession.php'; | ||
| require_once 'TestAddress.php'; | ||
| require_once 'TestExchangeRate.php'; | ||
| require_once 'TestMoney.php'; | ||
| -require_once 'TestSession.php'; | ||
| +require_once 'TestPublisher.php'; | ||
| +require_once 'TestOrder.php'; | ||
| +require_once 'TestSuite.php'; | ||
| /** | ||
| * Runs the entire test suite. | ||
| * | ||
| * Usage: php Main.php | ||
| * | ||
| * Return: 0 means no tests failed; 1 means at least one test failed. | ||
| */ | ||
| class Main { | ||
| - public function run() { | ||
| - // Test sessions first to ensure headers are sent before any output occurs. | ||
| + public function run(): int { | ||
| + // Test sessions first to ensure headers are sent before any output | ||
| + // occurs. | ||
| $tests = [ | ||
| new TestSession(), | ||
| new TestAddress(), | ||
| new TestExchangeRate(), | ||
| - new TestMoney() | ||
| + new TestMoney(), | ||
| + new TestPublisher(), | ||
| + new TestOrder() | ||
| ]; | ||
| $suite = new TestSuite(); |
| +<?php | ||
| +require_once '../Address.php'; | ||
| + | ||
| +class TestHarness { | ||
| + public const ADDRESSES = [ | ||
| + 'GB' => [ | ||
| + 'name' => 'The Ritz London', | ||
| + 'street1' => '150 Piccadilly', | ||
| + 'street2' => '520 Jermyn Street', | ||
| + 'city' => 'London', | ||
| + 'state' => 'Greater London', | ||
| + 'postcode' => 'SW1Y 6LX', | ||
| + 'country' => 'GB', | ||
| + 'phone' => '+44 20 7493 8181', | ||
| + 'email' => 'test@example.org' | ||
| + ], | ||
| + 'AU' => [ | ||
| + 'name' => 'Fitzroy Mills Market', | ||
| + 'street1' => '45 Brunswick Street', | ||
| + 'street2' => 'Fitzroy Mills Market', | ||
| + 'city' => 'Fitzroy', | ||
| + 'state' => 'Victoria', | ||
| + 'postcode' => '3065', | ||
| + 'country' => 'AU', | ||
| + 'phone' => '+61 3 9419 4444', | ||
| + 'email' => 'test@example.org' | ||
| + ], | ||
| + 'CA' => [ | ||
| + 'name' => 'Mount Dennis Community', | ||
| + 'street1' => '42 Weston Road', | ||
| + 'street2' => 'Mount Dennis', | ||
| + 'city' => 'Toronto', | ||
| + 'state' => 'Ontario', | ||
| + 'postcode' => 'm6n5h3', | ||
| + 'country' => 'CA', | ||
| + 'phone' => '1-416-555-0142', | ||
| + 'email' => 'test@example.org' | ||
| + ], | ||
| + 'US' => [ | ||
| + 'name' => 'Cal State Fullerton', | ||
| + 'street1' => '800 N State College Blvd', | ||
| + 'street2' => 'Cal State Fullerton', | ||
| + 'city' => 'Fullerton', | ||
| + 'state' => 'CA', | ||
| + 'postcode' => '92831', | ||
| + 'country' => 'US', | ||
| + 'phone' => '+1 657-278-2011', | ||
| + 'email' => 'test@example.org' | ||
| + ] | ||
| + ]; | ||
| + | ||
| + /** | ||
| + * Creates an Address object for the specified country. | ||
| + * | ||
| + * @param string $country The country code (US, CA, GB, AU). | ||
| + * | ||
| + * @return Address The address object. | ||
| + */ | ||
| + public function createAddress( string $country ): Address { | ||
| + return new Address( self::ADDRESSES[$country] ); | ||
| + } | ||
| +} | ||
| +<?php | ||
| +require_once '../Order.php'; | ||
| +require_once '../Publisher.php'; | ||
| +require_once '../Address.php'; | ||
| +require_once '../Configuration.php'; | ||
| +require_once 'TestHarness.php'; | ||
| +require_once 'TestableOrder.php'; | ||
| + | ||
| +class TestOrder { | ||
| + public function run( $callback ) { | ||
| + $callback( [ | ||
| + 'test_Order_Process_WithCode201_ProcessesSuccessfully', | ||
| + 'test_Order_Process_WithCode400_ProcessesError', | ||
| + 'test_Order_Process_WithCode500_ProcessesError' | ||
| + ] ); | ||
| + } | ||
| + | ||
| + public function test_Order_Process_WithCode201_ProcessesSuccessfully() { | ||
| + return $this->runOrderTest( 201 ); | ||
| + } | ||
| + | ||
| + public function test_Order_Process_WithCode400_ProcessesError() { | ||
| + return $this->runOrderTest( 400 ); | ||
| + } | ||
| + | ||
| + public function test_Order_Process_WithCode500_ProcessesError() { | ||
| + return $this->runOrderTest( 500 ); | ||
| + } | ||
| + | ||
| + private function runOrderTest( int $expectedCode ): bool { | ||
| + $harness = new TestHarness(); | ||
| + $publisher = $this->createPublisher( $expectedCode ); | ||
| + $address = $harness->createAddress( 'US' ); | ||
| + $order = new TestableOrder(); | ||
| + | ||
| + $publisher->process( $address, $order ); | ||
| + | ||
| + return $order->processed( $expectedCode ); | ||
| + } | ||
| + | ||
| + private function createPublisher( int $statusCode ): Publisher { | ||
| + $config = new Configuration(); | ||
| + $publisher = new TestablePublisher( $statusCode ); | ||
| + | ||
| + $config->load(); | ||
| + $config->configure( $publisher ); | ||
| + | ||
| + return $publisher; | ||
| + } | ||
| +} | ||
| + | ||
| +/** | ||
| + * Testable Publisher subclass that returns controlled responses. | ||
| + */ | ||
| +class TestablePublisher extends Publisher { | ||
| + private $statusCode; | ||
| + | ||
| + public function __construct( int $statusCode ) { | ||
| + $this->statusCode = $statusCode; | ||
| + } | ||
| + | ||
| + protected function post( string $path, string $content, string $header ) { | ||
| + return [ 'body' => [], 'code' => $this->statusCode ]; | ||
| + } | ||
| +} | ||
| +<?php | ||
| +require_once '../Publisher.php'; | ||
| +require_once '../Address.php'; | ||
| +require_once '../Order.php'; | ||
| +require_once '../Configuration.php'; | ||
| +require_once 'TestableOrder.php'; | ||
| +require_once 'TestHarness.php'; | ||
| + | ||
| +class TestPublisher { | ||
| + public function run( $callback ): void | ||
| + { | ||
| + $callback( [ | ||
| + 'test_Publisher_Process_WithUSAddress_UsesUSD', | ||
| + 'test_Publisher_Process_WithCanadianAddress_UsesCAD', | ||
| + 'test_Publisher_Process_WithUKAddress_UsesGBP', | ||
| + 'test_Publisher_Process_WithAustralianAddress_UsesAUD' | ||
| + ] ); | ||
| + } | ||
| + | ||
| + public function test_Publisher_Process_WithUSAddress_UsesUSD() { | ||
| + return $this->runPublisherTest( 'US' ); | ||
| + } | ||
| + | ||
| + public function test_Publisher_Process_WithCanadianAddress_UsesCAD() { | ||
| + return $this->runPublisherTest( 'CA' ); | ||
| + } | ||
| + | ||
| + public function test_Publisher_Process_WithUKAddress_UsesGBP() { | ||
| + return $this->runPublisherTest( 'GB' ); | ||
| + } | ||
| + | ||
| + public function test_Publisher_Process_WithAustralianAddress_UsesAUD() { | ||
| + return $this->runPublisherTest( 'AU' ); | ||
| + } | ||
| + | ||
| + private function runPublisherTest( string $country ): bool { | ||
| + $harness = new TestHarness(); | ||
| + $publisher = $this->createPublisher(); | ||
| + $address = $harness->createAddress( $country ); | ||
| + $order = new TestableOrder(); | ||
| + | ||
| + $publisher->process( $address, $order ); | ||
| + | ||
| + return $order->success(); | ||
| + } | ||
| + | ||
| + private function createPublisher(): Publisher { | ||
| + $config = new Configuration(); | ||
| + $publisher = new Publisher(); | ||
| + | ||
| + $config->load(); | ||
| + $config->configure( $publisher ); | ||
| + | ||
| + return $publisher; | ||
| + } | ||
| +} | ||
| +<?php | ||
| +require_once '../Order.php'; | ||
| + | ||
| +/** | ||
| + * Testable Order subclass that tracks processing. | ||
| + */ | ||
| +class TestableOrder extends Order { | ||
| + private bool $successful = false; | ||
| + private int $processedCode = 0; | ||
| + | ||
| + protected function processSuccess( array $response ): void { | ||
| + $this->successful = true; | ||
| + parent::processSuccess( $response ); | ||
| + } | ||
| + | ||
| + protected function processError( array $response, int $statusCode ): void { | ||
| + $this->successful = false; | ||
| + parent::processError( $response, $statusCode ); | ||
| + } | ||
| + | ||
| + public function process( array $response, int $statusCode ): void { | ||
| + $this->processedCode = $statusCode; | ||
| + parent::process( $response, $statusCode ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Checks if processing was successful. | ||
| + * | ||
| + * @return bool True if processing was successful. | ||
| + */ | ||
| + public function success(): bool { | ||
| + return $this->successful; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Checks if the order was processed with a specific status code. | ||
| + * | ||
| + * @param int $code The expected HTTP status code. | ||
| + * @return bool True if processed with the expected code. | ||
| + */ | ||
| + public function processed( int $code ): bool { | ||
| + return $this->processedCode === $code; | ||
| + } | ||
| +} | ||
| Delta | 360 lines added, 70 lines removed, 290-line increase |
|---|