feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
+672
View File
@@ -0,0 +1,672 @@
<?php
/**
* Minimal stub classes for Grav CMS types used in the API plugin.
*
* These are loaded ONLY when the real Grav classes are not available
* (i.e., when the plugin is tested outside of a Grav installation).
* They provide just enough structure for the plugin classes to be
* instantiated and tested in isolation.
*/
// Only declare stubs if the real classes don't exist.
// This file is loaded by the bootstrap's fallback autoloader.
namespace Grav\Common\Config {
if (!class_exists(\Grav\Common\Config\Config::class, false)) {
class Config
{
public function __construct(protected array $items = []) {}
public function get(string $key, mixed $default = null): mixed
{
$segments = explode('.', $key);
$current = $this->items;
foreach ($segments as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return $default;
}
$current = $current[$segment];
}
return $current;
}
public function set(string $key, mixed $value): void
{
$segments = explode('.', $key);
$current = &$this->items;
foreach ($segments as $i => $segment) {
if ($i === count($segments) - 1) {
$current[$segment] = $value;
} else {
if (!isset($current[$segment]) || !is_array($current[$segment])) {
$current[$segment] = [];
}
$current = &$current[$segment];
}
}
}
}
}
}
namespace Grav\Common {
if (!class_exists(\Grav\Common\Yaml::class, false)) {
// Thin shim over symfony/yaml so tests that need YAML parsing run
// without the full Grav core on the classpath.
abstract class Yaml
{
public static function parse(string $data): array
{
$parsed = \Symfony\Component\Yaml\Yaml::parse($data);
return is_array($parsed) ? $parsed : [];
}
public static function dump(mixed $data, ?int $inline = null, ?int $indent = null): string
{
return \Symfony\Component\Yaml\Yaml::dump($data, $inline ?? 5, $indent ?? 2);
}
}
}
if (!class_exists(\Grav\Common\Grav::class, false)) {
class Grav implements \ArrayAccess
{
private static ?self $instance = null;
private array $services = [];
/** @var array<int, array{name: string, event: object}> Recorded event firings for test assertions. */
private array $firedEvents = [];
public static function instance(): static
{
if (self::$instance === null) {
self::$instance = new static();
}
return self::$instance;
}
/** Reset the singleton (useful between tests). */
public static function resetInstance(): void
{
self::$instance = null;
}
/**
* Fire a Grav event (stub implementation).
* Records the event for later assertion in tests.
*/
public function fireEvent(string $name, ?object $event = null): object
{
$event = $event ?? new \stdClass();
$this->firedEvents[] = ['name' => $name, 'event' => $event];
return $event;
}
/**
* Get all recorded fired events (for test assertions).
* @return array<int, array{name: string, event: object}>
*/
public function getFiredEvents(): array
{
return $this->firedEvents;
}
/** Clear the recorded events list. */
public function clearFiredEvents(): void
{
$this->firedEvents = [];
}
public function offsetExists(mixed $offset): bool
{
return isset($this->services[$offset]);
}
public function offsetGet(mixed $offset): mixed
{
return $this->services[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->services[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->services[$offset]);
}
}
}
}
namespace Grav\Common\User {
if (!class_exists(\Grav\Common\User\Authentication::class, false)) {
/**
* Minimal Authentication stub mirroring Grav's password hashing helper.
* create() returns a bcrypt hash; verify() returns an int (1 = match,
* 0 = no match) matching the contract ApiKeyManager::verifyKey() relies on.
*/
abstract class Authentication
{
public static function create(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT);
}
public static function verify(string $password, string $hash): int
{
return password_verify($password, $hash) ? 1 : 0;
}
}
}
}
namespace Grav\Common\User\Interfaces {
if (!interface_exists(\Grav\Common\User\Interfaces\UserInterface::class, false)) {
interface UserInterface
{
public function get(string $key, mixed $default = null): mixed;
public function set(string $key, mixed $value): void;
public function save(): void;
public function exists(): bool;
}
}
if (!interface_exists(\Grav\Common\User\Interfaces\UserCollectionInterface::class, false)) {
interface UserCollectionInterface extends \Traversable
{
public function load(string $username): UserInterface;
}
}
}
namespace Grav\Common\Page\Interfaces {
if (!interface_exists(\Grav\Common\Page\Interfaces\PageInterface::class, false)) {
interface PageInterface
{
public function route($var = null): ?string;
public function slug($var = null): string;
public function order($var = null): ?int;
public function path($var = null): ?string;
public function title($var = null): string;
public function isModule(): bool;
public function children(): \Traversable;
}
}
}
namespace Grav\Common\Filesystem {
if (!class_exists(\Grav\Common\Filesystem\Folder::class, false)) {
class Folder
{
public static function move(string $source, string $target): void
{
if (is_dir($source)) {
rename($source, $target);
}
}
}
}
}
namespace Grav\Framework\Psr7 {
if (!class_exists(\Grav\Framework\Psr7\Response::class, false)) {
/**
* Minimal PSR-7 Response implementation for testing.
*/
class Response implements \Psr\Http\Message\ResponseInterface
{
/** @var array<string, string[]> */
private array $headerValues = [];
/** @var string */
private string $body;
public function __construct(
private int $statusCode = 200,
array $headers = [],
string $body = '',
private string $protocolVersion = '1.1',
private string $reasonPhrase = '',
) {
foreach ($headers as $name => $value) {
$this->headerValues[strtolower($name)] = [
'original' => $name,
'values' => is_array($value) ? $value : [$value],
];
}
$this->body = $body;
}
public function getStatusCode(): int { return $this->statusCode; }
public function withStatus(int $code, string $reasonPhrase = ''): static
{
$clone = clone $this;
$clone->statusCode = $code;
$clone->reasonPhrase = $reasonPhrase;
return $clone;
}
public function getReasonPhrase(): string { return $this->reasonPhrase; }
public function getProtocolVersion(): string { return $this->protocolVersion; }
public function withProtocolVersion(string $version): static
{
$clone = clone $this;
$clone->protocolVersion = $version;
return $clone;
}
public function getHeaders(): array
{
$result = [];
foreach ($this->headerValues as $info) {
$result[$info['original']] = $info['values'];
}
return $result;
}
public function hasHeader(string $name): bool
{
return isset($this->headerValues[strtolower($name)]);
}
public function getHeader(string $name): array
{
return $this->headerValues[strtolower($name)]['values'] ?? [];
}
public function getHeaderLine(string $name): string
{
return implode(', ', $this->getHeader($name));
}
public function withHeader(string $name, $value): static
{
$clone = clone $this;
$clone->headerValues[strtolower($name)] = [
'original' => $name,
'values' => is_array($value) ? $value : [$value],
];
return $clone;
}
public function withAddedHeader(string $name, $value): static
{
$clone = clone $this;
$lower = strtolower($name);
$existing = $clone->headerValues[$lower]['values'] ?? [];
$clone->headerValues[$lower] = [
'original' => $clone->headerValues[$lower]['original'] ?? $name,
'values' => array_merge($existing, is_array($value) ? $value : [$value]),
];
return $clone;
}
public function withoutHeader(string $name): static
{
$clone = clone $this;
unset($clone->headerValues[strtolower($name)]);
return $clone;
}
public function getBody(): \Psr\Http\Message\StreamInterface
{
$content = $this->body;
return new class ($content) implements \Psr\Http\Message\StreamInterface {
public function __construct(private readonly string $content) {}
public function __toString(): string { return $this->content; }
public function close(): void {}
public function detach() { return null; }
public function getSize(): ?int { return strlen($this->content); }
public function tell(): int { return 0; }
public function eof(): bool { return true; }
public function isSeekable(): bool { return false; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function isReadable(): bool { return true; }
public function read(int $length): string { return $this->content; }
public function getContents(): string { return $this->content; }
public function getMetadata(?string $key = null): mixed { return null; }
};
}
public function withBody(\Psr\Http\Message\StreamInterface $body): static
{
$clone = clone $this;
$clone->body = (string) $body;
return $clone;
}
}
}
}
namespace RocketTheme\Toolbox\Event {
if (!class_exists(\RocketTheme\Toolbox\Event\Event::class, false)) {
/**
* Minimal Event stub that supports array access for event data.
*/
class Event implements \ArrayAccess
{
private array $data;
public function __construct(array $data = [])
{
$this->data = $data;
}
public function offsetExists(mixed $offset): bool
{
return array_key_exists($offset, $this->data);
}
public function &offsetGet(mixed $offset): mixed
{
return $this->data[$offset];
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->data[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->data[$offset]);
}
public function toArray(): array
{
return $this->data;
}
}
}
}
namespace Grav\Common\Page {
if (!class_exists(\Grav\Common\Page\Page::class, false)) {
/**
* Minimal Page stub for testing controllers that instantiate Page directly.
*/
class Page
{
private ?string $filePath = null;
private object $header;
private string $rawMarkdown = '';
private string $template = 'default';
private string $name = 'default.md';
private ?string $path = null;
private ?string $route = null;
private ?string $slug = null;
private ?int $order = null;
private ?string $lang = null;
public function __construct()
{
$this->header = new \stdClass();
}
public function filePath(?string $path = null): ?string
{
if ($path !== null) {
$this->filePath = $path;
// Derive path (directory) from filePath
$this->path = dirname($path);
}
return $this->filePath;
}
public function header($var = null)
{
if ($var !== null) {
$this->header = is_array($var) ? (object) $var : $var;
}
return $this->header;
}
public function rawMarkdown(?string $var = null): string
{
if ($var !== null) {
$this->rawMarkdown = $var;
}
return $this->rawMarkdown;
}
public function template(?string $var = null): string
{
if ($var !== null) {
$this->template = $var;
}
return $this->template;
}
public function name(?string $var = null): string
{
if ($var !== null) {
$this->name = $var;
}
return $this->name;
}
public function path(?string $var = null): ?string
{
if ($var !== null) {
$this->path = $var;
}
return $this->path;
}
public function route($var = null): ?string
{
if ($var !== null) {
$this->route = $var;
}
return $this->route;
}
public function slug($var = null): ?string
{
if ($var !== null) {
$this->slug = $var;
}
return $this->slug;
}
public function order($var = null): ?int
{
if ($var !== null) {
$this->order = $var;
}
return $this->order;
}
public function language(?string $var = null): ?string
{
if ($var !== null) {
$this->lang = $var;
}
return $this->lang;
}
public function title($var = null): string
{
return $this->header->title ?? '';
}
public function save($reorder = true): void
{
// No-op in tests — the actual file writing is not needed
}
public function isModule(): bool
{
return false;
}
public function children(): \Traversable
{
return new \ArrayIterator([]);
}
public function translatedLanguages(): array
{
return [];
}
public function file(): ?object
{
return null;
}
public function content($var = null): string
{
return $this->rawMarkdown;
}
}
}
if (!class_exists(\Grav\Common\Page\Media::class, false)) {
/**
* Minimal Media stub.
*/
class Media
{
public function __construct(private readonly ?string $path = null) {}
public function all(): array
{
return [];
}
}
}
}
namespace Grav\Common\GPM {
if (!class_exists(\Grav\Common\GPM\GPM::class, false)) {
/**
* Minimal GPM stub. Methods are intentionally non-final and present
* here only so PHPUnit's createMock() can produce a mock subclass.
* Behavior is supplied per-test via mock expectations.
*/
class GPM
{
public function __construct(bool $refresh = false, $callback = null) {}
public function getUpdatable(): array { return []; }
public function isUpdatable(string $slug): bool { return false; }
public function checkPackagesCanBeInstalled(array $slugs): void {}
public function getDependencies(array $slugs): array { return []; }
}
}
}
namespace Grav\Common {
if (!class_exists(\Grav\Common\Utils::class, false)) {
/**
* Minimal Utils stub. Exercised by unit tests via PermissionResolver
* (arrayFlattenDotNotation) and UsersController's permission filtering
* (isPositive).
*/
class Utils
{
public static function arrayFlattenDotNotation(array $array, string $prepend = ''): array
{
$results = [];
foreach ($array as $key => $value) {
if (is_array($value) && !empty($value)) {
$results = array_merge($results, self::arrayFlattenDotNotation($value, $prepend . $key . '.'));
} else {
$results[$prepend . $key] = $value;
}
}
return $results;
}
public static function isPositive($value): bool
{
return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true);
}
/**
* Exercised by UploadFieldSettings (random_name). The real Utils
* draws from a larger alphabet; a deterministic-length lowercase
* alnum string is enough for the upload-pipeline tests.
*/
public static function generateRandomString($length = 5): string
{
$alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
$out = '';
for ($i = 0; $i < $length; $i++) {
$out .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return $out;
}
/**
* Exercised by UploadFieldSettings (accept allowlist). Maps the few
* extensions the tests rely on; everything else is octet-stream,
* matching the real Utils' fallback when no media type is found.
*/
public static function getMimeByFilename($filename, $default = 'application/octet-stream'): string
{
$ext = strtolower(pathinfo((string) $filename, PATHINFO_EXTENSION));
return match ($ext) {
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'pdf' => 'application/pdf',
'txt' => 'text/plain',
default => $default,
};
}
}
}
}
namespace Grav\Framework\Acl {
if (!class_exists(\Grav\Framework\Acl\Permissions::class, false)) {
/**
* Minimal Permissions stub so PermissionResolver can be constructed.
* Only resolvedMap() touches getInstances(); resolve() reads only the
* user's access array, so most unit tests get away with an empty stub.
*/
class Permissions
{
/** @return array<string, object> */
public function getInstances(): array { return []; }
}
}
}
namespace Grav\Common\Data {
if (!class_exists(\Grav\Common\Data\Data::class, false)) {
/**
* Minimal Data stub for config wrapping.
*/
class Data
{
public function __construct(private array $items = []) {}
public function toArray(): array
{
return $this->items;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->items[$key] ?? $default;
}
public function set(string $key, mixed $value): void
{
$this->items[$key] = $value;
}
}
}
}