Compare commits

...
13 Commits
11 changed files with 9310 additions and 329 deletions
+34
View File
@@ -0,0 +1,34 @@
name: Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: ["8.4"]
name: PHP ${{ matrix.php }}
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run tests
run: vendor/bin/pest
-29
View File
@@ -1,29 +0,0 @@
<?php
$finder = PhpCsFixer\Finder::create()
->exclude('bootstrap/cache')
->exclude('storage')
->exclude('vendor')
->in(__DIR__)
->name('*.php')
->notName('*.blade.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
$rules = [
'@PSR12' => true,
'array_syntax' => ['syntax' => 'short'],
'binary_operator_spaces' => ['operators' => ['=>' => null]],
'linebreak_after_opening_tag' => true,
'ordered_imports' => ['sort_algorithm' => 'length'],
'phpdoc_order' => true,
'phpdoc_no_empty_return' => false,
'yoda_style' => false,
'no_unused_imports' => true,
];
$config = new PhpCsFixer\Config();
return $config->setRules($rules)
->setFinder($finder)
->setUsingCache(false);
+45 -24
View File
@@ -1,26 +1,47 @@
{ {
"name": "wearepixel/laravel-google-shopping-feed", "name": "wearepixel/laravel-google-shopping-feed",
"type": "library", "type": "library",
"description": "Laravel Google Shopping Feed", "description": "Laravel Google Shopping Feed",
"keywords": [ "keywords": [
"laravel", "laravel",
"google-shopping-feed" "google-shopping-feed"
], ],
"version": "4.0.0", "version": "5.1.0",
"homepage": "https://github.com/wearepixel/laravel-google-shopping-feed", "homepage": "https://github.com/wearepixel/laravel-google-shopping-feed",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
{ {
"name": "Pixel", "name": "Pixel",
"email": "hello@wearepixel.com.au" "email": "hello@wearepixel.com.au"
} }
], ],
"autoload": { "autoload": {
"psr-4": { "Wearepixel\\LaravelGoogleShoppingFeed\\" : "src/" } "psr-4": {
}, "Wearepixel\\LaravelGoogleShoppingFeed\\": "src/"
"require": { }
"spatie/array-to-xml": "2.8.*|3.*.*", },
"illuminate/support": "8.*|9.*|10.*|11.*" "autoload-dev": {
}, "psr-4": {
"prefer-stable": true "Tests\\": "tests/"
}
},
"require": {
"php": ">=8.4",
"spatie/array-to-xml": "2.8.*|3.*",
"illuminate/support": "10.*|11.*|12.*|13.*"
},
"prefer-stable": true,
"require-dev": {
"laravel/pint": "^1.29",
"pestphp/pest": "^4.0",
"orchestra/testbench": "^10.0"
},
"scripts": {
"test": "vendor/bin/pest"
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
} }
Generated
+8970 -241
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
+13
View File
@@ -0,0 +1,13 @@
{
"preset": "laravel",
"rules": {
"concat_space": {
"spacing": "one"
},
"class_attributes_separation": false,
"ordered_imports": {
"sort_algorithm": "length"
},
"not_operator_with_space": false
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Wearepixel\LaravelGoogleShoppingFeed\Exceptions;
use RuntimeException;
class MissingRequiredFieldException extends RuntimeException {}
+33 -35
View File
@@ -1,31 +1,33 @@
<?php <?php
declare(strict_types=1);
namespace Wearepixel\LaravelGoogleShoppingFeed; namespace Wearepixel\LaravelGoogleShoppingFeed;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Response as ResponseFacade;
use Spatie\ArrayToXml\ArrayToXml; use Spatie\ArrayToXml\ArrayToXml;
use Wearepixel\LaravelGoogleShoppingFeed\Exceptions\MissingRequiredFieldException;
class LaravelGoogleShoppingFeed class LaravelGoogleShoppingFeed
{ {
public $title; public readonly string $title;
public $description; public readonly string $description;
public $link; public readonly string $link;
protected $xml = []; protected array $products = [];
protected $products = [];
protected $currency = "AUD";
protected $requiredProductFields = [ protected const array REQUIRED_PRODUCT_FIELDS = [
'id', 'id',
'link', 'link',
'title', 'title',
'g:price', 'g:price',
'g:image_link' 'g:image_link',
]; ];
public static function init($title = null, $description = null, $link = null) public static function init(string $title = '', string $description = '', string $link = ''): static
{ {
$feed = new self(); $feed = new static;
$feed->title = $title; $feed->title = $title;
$feed->description = $description; $feed->description = $description;
$feed->link = $link; $feed->link = $link;
@@ -33,26 +35,20 @@ public static function init($title = null, $description = null, $link = null)
return $feed; return $feed;
} }
public function addItem(array $item): bool public function addItem(array $item): void
{ {
foreach ($this->requiredProductFields as $field) { foreach (self::REQUIRED_PRODUCT_FIELDS as $field) {
if (!isset($item[$field])) { if (! isset($item[$field])) {
throw new \Exception("Required field '{$field}' is missing"); throw new MissingRequiredFieldException("Required field '{$field}' is missing");
} }
} }
foreach ($item as $key => $value) { $this->products[] = $item;
$product[$key] = $value;
}
$this->products[] = $product;
return true;
} }
public function generate() public function toXml(): string
{ {
$this->xml = [ $data = [
'rss' => [ 'rss' => [
'_attributes' => [ '_attributes' => [
'xmlns:g' => 'http://base.google.com/ns/1.0', 'xmlns:g' => 'http://base.google.com/ns/1.0',
@@ -62,23 +58,25 @@ public function generate()
'title' => $this->title, 'title' => $this->title,
'description' => $this->description, 'description' => $this->description,
'link' => $this->link, 'link' => $this->link,
] ],
] ],
]; ];
foreach ($this->products as $key => $product) { foreach ($this->products as $key => $product) {
$this->xml['rss']['channel']['item_'.$key] = $product; $data['rss']['channel']['item_' . $key] = $product;
} }
$xml = ArrayToXml::convert($this->xml, ''); $xml = ArrayToXml::convert($data, '');
$xml = str_replace([' ', '<root>', '</root>', "\n", "\r", '<remove>remove</remove>'], '', $xml); $xml = str_replace([' ', '<root>', '</root>', "\n", "\r", '<remove>remove</remove>'], '', $xml);
$xml = preg_replace([ // spatie/array-to-xml does not support repeated keys, so products are keyed as
"/item_[0-9][0-9][0-9][0-9]/", // item_0, item_1, etc. and renamed back to <item> tags here.
"/item_[0-9][0-9][0-9]/", $xml = (string) preg_replace('/item_\d+/', 'item', $xml);
"/item_[0-9][0-9]/",
"/item_[0-9]/",
], "item", $xml);
return response($xml)->header('Content-Type', 'text/xml'); return $xml;
}
public function generate(): Response
{
return ResponseFacade::make($this->toXml(), headers: ['Content-Type' => 'application/rss+xml']);
} }
} }
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
use Wearepixel\LaravelGoogleShoppingFeed\Exceptions\MissingRequiredFieldException;
use Wearepixel\LaravelGoogleShoppingFeed\LaravelGoogleShoppingFeed;
function validProduct(array $overrides = []): array
{
return array_merge([
'id' => 'prod-1',
'link' => 'https://example.com/product',
'title' => 'Test Product',
'g:price' => '29.99 AUD',
'g:image_link' => 'https://example.com/image.jpg',
], $overrides);
}
describe('init', function () {
it('stores title, description, and link', function () {
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Desc', 'https://example.com');
expect($feed->title)->toBe('My Store')
->and($feed->description)->toBe('My Desc')
->and($feed->link)->toBe('https://example.com');
});
it('defaults to empty strings', function () {
$feed = LaravelGoogleShoppingFeed::init();
expect($feed->title)->toBe('')
->and($feed->description)->toBe('')
->and($feed->link)->toBe('');
});
});
describe('addItem', function () {
it('accepts a valid item without throwing', function () {
$feed = LaravelGoogleShoppingFeed::init('', '', '');
$feed->addItem(validProduct());
expect($feed->toXml())->toContain('prod-1');
});
it('throws for missing required field', function (string $field) {
$product = validProduct();
unset($product[$field]);
expect(fn () => LaravelGoogleShoppingFeed::init()->addItem($product))
->toThrow(MissingRequiredFieldException::class);
})->with(['id', 'link', 'title', 'g:price', 'g:image_link']);
it('throws with a message containing the field name', function () {
$product = validProduct();
unset($product['g:price']);
expect(fn () => LaravelGoogleShoppingFeed::init()->addItem($product))
->toThrow(MissingRequiredFieldException::class, "g:price");
});
});
describe('toXml', function () {
it('returns a string', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
expect($feed->toXml())->toBeString();
});
it('contains the RSS xmlns:g attribute', function () {
$feed = LaravelGoogleShoppingFeed::init();
$xml = $feed->toXml();
expect($xml)->toContain('xmlns:g="http://base.google.com/ns/1.0"');
});
it('contains the RSS version attribute', function () {
$feed = LaravelGoogleShoppingFeed::init();
$xml = $feed->toXml();
expect($xml)->toContain('version="2.0"');
});
it('includes the channel title, description and link', function () {
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Description', 'https://example.com');
$xml = $feed->toXml();
expect($xml)
->toContain('<title>My Store</title>')
->toContain('<description>My Description</description>')
->toContain('<link>https://example.com</link>');
});
it('includes product data', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct());
$xml = $feed->toXml();
expect($xml)
->toContain('<id>prod-1</id>')
->toContain('<title>Test Product</title>');
});
it('wraps products in item tags', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct());
$xml = $feed->toXml();
expect($xml)
->toContain('<item>')
->toContain('</item>');
});
it('handles multiple products', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct(['id' => 'prod-1']));
$feed->addItem(validProduct(['id' => 'prod-2']));
$xml = $feed->toXml();
expect(substr_count($xml, '<item>'))->toBe(2)
->and($xml)->toContain('<id>prod-1</id>')
->and($xml)->toContain('<id>prod-2</id>');
});
it('includes namespaced google fields in output', function () {
$feed = LaravelGoogleShoppingFeed::init('', '', '');
$feed->addItem(validProduct());
$xml = $feed->toXml();
expect($xml)
->toContain('<g:price>29.99 AUD</g:price>')
->toContain('<g:image_link>https://example.com/image.jpg</g:image_link>');
});
it('generates valid xml with no products', function () {
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Desc', 'https://example.com');
$xml = $feed->toXml();
expect($xml)
->toBeString()
->toContain('<title>My Store</title>')
->not->toContain('<item>');
});
it('includes the xml declaration', function () {
$feed = LaravelGoogleShoppingFeed::init('', '', '');
expect($feed->toXml())->toStartWith('<?xml');
});
it('generates parseable xml', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct());
$result = simplexml_load_string($feed->toXml());
expect($result)->not->toBeFalse();
});
});
describe('generate', function () {
it('returns an HTTP 200 response', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$response = $feed->generate();
expect($response->getStatusCode())->toBe(200);
});
it('sets the content type to application/rss+xml', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$response = $feed->generate();
expect($response->headers->get('Content-Type'))->toContain('application/rss+xml');
});
it('returns the xml in the response body', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct());
$response = $feed->generate();
expect($response->getContent())->toBe($feed->toXml());
});
});
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
uses(Tests\TestCase::class)->in('.');
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Tests;
use Orchestra\Testbench\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase {}